@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.
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/package.json +39 -0
- 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
|
+
};
|