@verifyhash/human-time 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 +128 -0
  3. package/package.json +39 -0
  4. package/src/index.js +193 -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,128 @@
1
+ # human-time
2
+
3
+ A tiny, **zero-dependency** Node.js library for two everyday time chores:
4
+
5
+ 1. **Relative phrases** — turn a timestamp into `"5 minutes ago"` or `"in 3 days"`.
6
+ 2. **Compact durations** — turn milliseconds into `"2h30m"` and back again.
7
+
8
+ No install step, no `node_modules`, no network, no servers. One file:
9
+ [`src/index.js`](src/index.js). Node 12+.
10
+
11
+ > **English only.** Output is fixed English. There is no locale/pluralization
12
+ > layer here on purpose — if you need localized phrasing, reach for
13
+ > `Intl.RelativeTimeFormat` or a full i18n library. This module optimizes for
14
+ > being small, dependency-free, and predictable.
15
+
16
+ ## Who it's for
17
+
18
+ Anyone rendering timestamps or durations in a CLI, log line, dashboard, or
19
+ small web app who wants friendly output without pulling in `moment`, `dayjs`,
20
+ `date-fns`, or `ms`. It is deliberately **distinct from the sibling
21
+ `iso-duration` library**: `iso-duration` parses/formats ISO 8601 durations
22
+ (`PT1H30M`) and does calendar-correct date arithmetic; `human-time` does
23
+ approximate human phrasing and compact `ms`-style strings.
24
+
25
+ ## Install / use
26
+
27
+ Copy the folder in, or `require` it directly — there is nothing to build.
28
+
29
+ ```js
30
+ const { relative, format, parse } = require('human-time'); // main = src/index.js
31
+ ```
32
+
33
+ ## API
34
+
35
+ ### `relative(from, to?) -> string`
36
+
37
+ Describes `from` as seen from the reference `to`. Both arguments accept a
38
+ `Date` **or** epoch-milliseconds (a `number`). `to` defaults to `Date.now()`
39
+ when omitted — so the common one-argument call just works:
40
+
41
+ ```js
42
+ relative(Date.now() - 5 * 60 * 1000); // "5 minutes ago"
43
+ relative(Date.now() + 90 * 60 * 1000); // "in 2 hours"
44
+ relative(new Date('2000-01-01'), new Date('2000-01-01')); // "just now"
45
+ ```
46
+
47
+ Direction is chosen so single-argument calls read naturally: when `from` is
48
+ earlier than `to` you get past tense (`"N ... ago"`); when `from` is later you
49
+ get future tense (`"in N ..."`). Anything under one second, in either
50
+ direction, is `"just now"`.
51
+
52
+ The threshold ladder (each band rounds to its nearest whole unit):
53
+
54
+ | Gap | Output examples |
55
+ | ----------------------- | ---------------------------- |
56
+ | `< 1s` | `just now` |
57
+ | `1s` … `< 1m` | `1 second ago` … `59 seconds ago` |
58
+ | `1m` … `< 1h` | `5 minutes ago`, `in 12 minutes` |
59
+ | `1h` … `< 1d` | `3 hours ago` |
60
+ | `1d` … `< 1w` | `3 days ago` |
61
+ | `1w` … `< 1 month` | `2 weeks ago` |
62
+ | `1 month` … `< 1 year` | `3 months ago` |
63
+ | `>= 1 year` | `2 years ago`, `in 5 years` |
64
+
65
+ **Honest limits:** months and years use *average* Gregorian lengths
66
+ (30.436875 and 365.2425 days), so `"3 months ago"` is approximate, never
67
+ calendar-exact — if you need "exactly 3 calendar months", use date arithmetic
68
+ (e.g. the `iso-duration` sibling), not this. Values sit in a single band and
69
+ round, so a 25-hour gap reads `"1 day ago"`, not `"1 day 1 hour ago"`.
70
+
71
+ ### `format(ms) -> string`
72
+
73
+ Compact, `ms`-package-style duration string. Emits every non-zero component
74
+ from days down to milliseconds, largest first, so it round-trips through
75
+ `parse`:
76
+
77
+ ```js
78
+ format(9_000_000); // "2h30m"
79
+ format(24*3600*1000 + 4*3600*1000); // "1d4h"
80
+ format(45_000); // "45s"
81
+ format(500); // "500ms"
82
+ format(1_500); // "1s500ms"
83
+ format(0); // "0ms"
84
+ format(-5_000); // "-5s"
85
+ ```
86
+
87
+ Fractional input is rounded to the nearest millisecond. Non-finite input
88
+ throws.
89
+
90
+ ### `parse(str) -> number` (milliseconds)
91
+
92
+ The inverse of `format`. Case-insensitive and tolerant of spacing. Understands
93
+ `d`, `h`, `m`, `s`, and `ms`, plus an optional leading `-`/`+`:
94
+
95
+ ```js
96
+ parse('2h30m'); // 9000000
97
+ parse('2H 30M'); // 9000000 (case + spacing don't matter)
98
+ parse('1d4h'); // 100800000
99
+ parse('500ms'); // 500
100
+ parse('-1m30s'); // -90000
101
+ parse('1.5h'); // 5400000 (fractional units allowed)
102
+ ```
103
+
104
+ Empty, garbage, or unknown-unit input **throws** rather than silently
105
+ returning `0`, so typos surface loudly.
106
+
107
+ ### `units`
108
+
109
+ The unit sizes in milliseconds are exposed as
110
+ `units.SECOND / MINUTE / HOUR / DAY / WEEK / MONTH / YEAR` for callers who want
111
+ the same constants.
112
+
113
+ ## Running the tests
114
+
115
+ One command, no dependencies:
116
+
117
+ ```
118
+ node test/index.test.js
119
+ ```
120
+
121
+ It exits `0` when all assertions pass (and prints a pass/fail count). The suite
122
+ covers past **and** future phrasing, every threshold band, `format`⇄`parse`
123
+ round-trips, sub-second inputs, negative/zero durations, and invalid-input
124
+ error handling.
125
+
126
+ ## License
127
+
128
+ MIT — see [LICENSE](LICENSE).
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@verifyhash/human-time",
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency human-readable relative time ('5 minutes ago', 'in 3 days') plus compact ms-style duration format/parse ('2h30m' <-> ms) for Node.js. English-only.",
5
+ "keywords": [
6
+ "relative-time",
7
+ "time-ago",
8
+ "humanize",
9
+ "duration",
10
+ "compact",
11
+ "format",
12
+ "parse",
13
+ "ms",
14
+ "2h30m",
15
+ "zero-dependency"
16
+ ],
17
+ "main": "src/index.js",
18
+ "scripts": {
19
+ "test": "node test/index.test.js"
20
+ },
21
+ "license": "MIT",
22
+ "files": [
23
+ "src/index.js",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/verifyhash/libs.git",
33
+ "directory": "human-time"
34
+ },
35
+ "homepage": "https://github.com/verifyhash/libs/tree/main/human-time#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/verifyhash/libs/issues"
38
+ }
39
+ }
package/src/index.js ADDED
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * human-time — zero-dependency human-readable relative time and compact
5
+ * ms-style duration formatting/parsing for Node.js.
6
+ *
7
+ * OUTPUT IS ENGLISH-ONLY. There is no locale/i18n layer here by design; if you
8
+ * need localized phrasing use Intl.RelativeTimeFormat or a full i18n library.
9
+ *
10
+ * This is intentionally DISTINCT from tools/iso-duration, which deals with
11
+ * ISO 8601 durations (PT1H30M) and calendar arithmetic. This module deals with
12
+ * (1) friendly "5 minutes ago" / "in 3 days" phrases and (2) compact "2h30m"
13
+ * style strings that survive a format<->parse round trip.
14
+ */
15
+
16
+ // Unit sizes in milliseconds. Months and years use average calendar lengths
17
+ // (Gregorian mean month/year) because relative phrasing is approximate by
18
+ // nature — "3 months ago" never needs to be calendar-exact.
19
+ var SEC = 1000;
20
+ var MIN = 60 * SEC;
21
+ var HOUR = 60 * MIN;
22
+ var DAY = 24 * HOUR;
23
+ var WEEK = 7 * DAY;
24
+ var MONTH = Math.round(30.436875 * DAY); // mean Gregorian month
25
+ var YEAR = Math.round(365.2425 * DAY); // mean Gregorian year
26
+
27
+ // Convert a Date or epoch-milliseconds value into a numeric timestamp.
28
+ function toMillis(value, label) {
29
+ if (value instanceof Date) {
30
+ var t = value.getTime();
31
+ if (isNaN(t)) throw new TypeError(label + ': invalid Date');
32
+ return t;
33
+ }
34
+ if (typeof value === 'number') {
35
+ if (!isFinite(value)) throw new TypeError(label + ': non-finite number');
36
+ return value;
37
+ }
38
+ throw new TypeError(label + ': expected a Date or epoch-milliseconds number');
39
+ }
40
+
41
+ /**
42
+ * relative(from, to) -> English relative phrase.
43
+ *
44
+ * `from` and `to` may each be a Date or epoch-milliseconds. `to` defaults to
45
+ * the current time (Date.now()) when omitted.
46
+ *
47
+ * The phrase describes `from` as seen from the reference `to`. When `from` is
48
+ * earlier than `to` it is past-tense ("N ... ago"); when `from` is later than
49
+ * `to` it is future-tense ("in N ..."). This makes the common single-argument
50
+ * call read naturally: relative(fiveMinutesAgo) === "5 minutes ago", and
51
+ * relative(fiveMinutesFromNow) === "in 5 minutes". Sub-second gaps in either
52
+ * direction collapse to "just now".
53
+ */
54
+ function relative(from, to) {
55
+ var f = toMillis(from, 'from');
56
+ var t = to === undefined ? Date.now() : toMillis(to, 'to');
57
+
58
+ var delta = f - t; // > 0 => `from` is later than the reference => future
59
+ var abs = Math.abs(delta);
60
+ var future = delta > 0;
61
+
62
+ if (abs < SEC) return 'just now';
63
+
64
+ var n, unit;
65
+ if (abs < MIN) {
66
+ n = Math.round(abs / SEC);
67
+ unit = 'second';
68
+ } else if (abs < HOUR) {
69
+ n = Math.round(abs / MIN);
70
+ unit = 'minute';
71
+ } else if (abs < DAY) {
72
+ n = Math.round(abs / HOUR);
73
+ unit = 'hour';
74
+ } else if (abs < WEEK) {
75
+ n = Math.round(abs / DAY);
76
+ unit = 'day';
77
+ } else if (abs < MONTH) {
78
+ n = Math.round(abs / WEEK);
79
+ unit = 'week';
80
+ } else if (abs < YEAR) {
81
+ n = Math.round(abs / MONTH);
82
+ unit = 'month';
83
+ } else {
84
+ n = Math.round(abs / YEAR);
85
+ unit = 'year';
86
+ }
87
+
88
+ var phrase = n + ' ' + unit + (n === 1 ? '' : 's');
89
+ return future ? 'in ' + phrase : phrase + ' ago';
90
+ }
91
+
92
+ // Ordered largest-to-smallest so format emits e.g. "1d4h" not "4h1d".
93
+ var FORMAT_UNITS = [
94
+ ['d', DAY],
95
+ ['h', HOUR],
96
+ ['m', MIN],
97
+ ['s', SEC],
98
+ ['ms', 1]
99
+ ];
100
+
101
+ /**
102
+ * format(ms) -> compact string, e.g. 9000000 -> "2h30m", 45000 -> "45s".
103
+ *
104
+ * Emits every non-zero component from days down to milliseconds, so the result
105
+ * round-trips exactly through parse(). Zero yields "0ms". Negative durations
106
+ * are prefixed with "-". Fractional input is rounded to the nearest ms.
107
+ */
108
+ function format(ms) {
109
+ var value = Number(ms);
110
+ if (!isFinite(value)) {
111
+ throw new TypeError('format: expected a finite number of milliseconds');
112
+ }
113
+ value = Math.round(value);
114
+ if (value === 0) return '0ms';
115
+
116
+ var sign = value < 0 ? '-' : '';
117
+ var rem = Math.abs(value);
118
+ var out = '';
119
+
120
+ for (var i = 0; i < FORMAT_UNITS.length; i++) {
121
+ var label = FORMAT_UNITS[i][0];
122
+ var size = FORMAT_UNITS[i][1];
123
+ if (rem >= size) {
124
+ var count = Math.floor(rem / size);
125
+ rem -= count * size;
126
+ out += count + label;
127
+ }
128
+ }
129
+ return sign + out;
130
+ }
131
+
132
+ // Longest-first alternation so "ms" is matched before a bare "m".
133
+ var TOKEN_RE = /(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)/g;
134
+ var UNIT_TO_MS = { ms: 1, s: SEC, m: MIN, h: HOUR, d: DAY };
135
+
136
+ /**
137
+ * parse(str) -> milliseconds, the inverse of format().
138
+ *
139
+ * Case-insensitive and tolerant of internal spacing: "2h30m", "2H 30M" and
140
+ * " 2h 30m " all yield 9000000. Understands d, h, m, s and ms. A leading "-"
141
+ * (or "+") sets the sign. Throws on empty/garbage input or unknown units so
142
+ * mistakes surface loudly instead of silently returning 0.
143
+ */
144
+ function parse(str) {
145
+ if (typeof str !== 'string') {
146
+ throw new TypeError('parse: expected a string');
147
+ }
148
+ var s = str.trim().toLowerCase();
149
+ if (s === '') throw new Error('parse: empty string');
150
+
151
+ var sign = 1;
152
+ if (s.charAt(0) === '-') {
153
+ sign = -1;
154
+ s = s.slice(1).trim();
155
+ } else if (s.charAt(0) === '+') {
156
+ s = s.slice(1).trim();
157
+ }
158
+
159
+ // Verify the whole body is nothing but recognized tokens (+ whitespace).
160
+ var leftover = s.replace(TOKEN_RE, '').replace(/\s+/g, '');
161
+ if (leftover !== '') {
162
+ throw new Error('parse: unexpected content in "' + str + '"');
163
+ }
164
+
165
+ TOKEN_RE.lastIndex = 0;
166
+ var total = 0;
167
+ var matched = false;
168
+ var m;
169
+ while ((m = TOKEN_RE.exec(s)) !== null) {
170
+ matched = true;
171
+ total += parseFloat(m[1]) * UNIT_TO_MS[m[2]];
172
+ }
173
+ if (!matched) {
174
+ throw new Error('parse: no recognizable duration in "' + str + '"');
175
+ }
176
+ return sign * total;
177
+ }
178
+
179
+ module.exports = {
180
+ relative: relative,
181
+ format: format,
182
+ parse: parse,
183
+ // Exposed for callers who want the same unit constants (all in ms).
184
+ units: {
185
+ SECOND: SEC,
186
+ MINUTE: MIN,
187
+ HOUR: HOUR,
188
+ DAY: DAY,
189
+ WEEK: WEEK,
190
+ MONTH: MONTH,
191
+ YEAR: YEAR
192
+ }
193
+ };