humane-ts 0.0.0-stage → 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 +125 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/size.d.ts +1 -0
- package/dist/size.js +24 -0
- package/dist/time.d.ts +7 -0
- package/dist/time.js +50 -0
- package/package.json +50 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Woodell
|
|
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
CHANGED
|
@@ -1,3 +1,126 @@
|
|
|
1
|
-
#
|
|
1
|
+
# humane-ts
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.typescriptlang.org/)
|
|
4
|
+
[](https://github.com/woodie/humane-ts/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/woodie/humane-ts/releases/latest)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
File sizes that read exactly the way macOS Finder shows them (`"4.1 MB"`,
|
|
9
|
+
`"226 KB"`), and relative dates in Rails ActionView's wording (`"about 3 hours
|
|
10
|
+
ago"`, `"in 3 minutes"`), for TypeScript and JavaScript. No dependencies.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { humanSize, timeAgo, distanceInTime } from 'humane-ts'
|
|
14
|
+
|
|
15
|
+
humanSize(225_935) // "226 KB"
|
|
16
|
+
|
|
17
|
+
const mtime = new Date(Date.now() - 180_000)
|
|
18
|
+
timeAgo(mtime) // "3 minutes ago" -- relative to the real clock
|
|
19
|
+
|
|
20
|
+
const now = new Date()
|
|
21
|
+
distanceInTime(mtime, now) // "3 minutes ago" -- explicit relativeTo, for tests
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
npm install humane-ts
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
To install the latest `main` instead, straight from GitHub (built on install):
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
npm install github:woodie/humane-ts
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
ES module only, with TypeScript types included.
|
|
37
|
+
|
|
38
|
+
## File sizes
|
|
39
|
+
|
|
40
|
+
`humanSize` matches Foundation's `ByteCountFormatter` (file style), which is
|
|
41
|
+
what macOS Finder shows, and is checked against real Finder output:
|
|
42
|
+
|
|
43
|
+
- 1000-based units with capitalized labels: `KB`, `MB`, `GB`, `TB`, and up.
|
|
44
|
+
- A fixed number of decimal places per unit -- none for KB, one for MB, two
|
|
45
|
+
for GB and above -- with trailing zeros dropped (`"2 MB"`, not `"2.0 MB"`).
|
|
46
|
+
- Rounds half up, and moves up a unit when rounding reaches 1000:
|
|
47
|
+
999,999 bytes is `"1 MB"`, not `"1000 KB"`.
|
|
48
|
+
- Zero bytes is `"Zero KB"`, and anything under 1000 bytes is spelled out
|
|
49
|
+
(`"1 byte"`, `"7 bytes"`).
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
humanSize(2_579) // "3 KB"
|
|
53
|
+
humanSize(4_132_383) // "4.1 MB"
|
|
54
|
+
humanSize(999_999) // "1 MB"
|
|
55
|
+
humanSize(5_240_000_000) // "5.24 GB"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Popular alternatives like `pretty-bytes` and `filesize` round differently
|
|
59
|
+
(`"4.13 MB"`, `"2.58 kB"`) and label units in SI style (`kB`, `B`), so their
|
|
60
|
+
output won't match what Finder shows.
|
|
61
|
+
|
|
62
|
+
## `distanceInTime` and `timeAgo`
|
|
63
|
+
|
|
64
|
+
Two entry points, same naming split as ActionView's own
|
|
65
|
+
`distance_of_time_in_words`/`time_ago_in_words`:
|
|
66
|
+
|
|
67
|
+
- **`distanceInTime(at, relativeTo, options?)`** -- the explicit,
|
|
68
|
+
fully-tested core. Takes both times, plus a possibly-null `at`, so it's
|
|
69
|
+
what tests should call.
|
|
70
|
+
- **`timeAgo(at, options?)`** -- a one-argument convenience for the common
|
|
71
|
+
"drop into a view" case. Supplies the current time as `relativeTo`.
|
|
72
|
+
|
|
73
|
+
The wording follows ActionView's `distance_of_time_in_words`, with one
|
|
74
|
+
difference: direction is picked for you. A past time reads `"3 minutes ago"`
|
|
75
|
+
and a future one `"in 3 minutes"`, where ActionView leaves that to the caller.
|
|
76
|
+
|
|
77
|
+
Both take the same options, with recommended defaults that already match
|
|
78
|
+
ActionView's own -- pass none at all and you get them for free:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
distanceInTime(at, relativeTo) // { approximate: true, includeSeconds: false }
|
|
82
|
+
timeAgo(at) // same defaults, relativeTo is now
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- **`approximate`** (default `true`): prefixes `"about"`/`"in about"` on the
|
|
86
|
+
hour-scale buckets (1 hour, and 2..24 hours), matching ActionView's
|
|
87
|
+
`distance_of_time_in_words` wording for those buckets exactly (down to its
|
|
88
|
+
44:30/89:30 rounding cutoffs), through the "1 day" bucket.
|
|
89
|
+
- **`includeSeconds`** (default `false`): under 30 seconds, collapses to
|
|
90
|
+
`"less than a minute ago"`/`"in less than a minute"` instead of an exact
|
|
91
|
+
second count. Matches ActionView's `include_seconds` default.
|
|
92
|
+
- **`whenNil`** (default `""`): if `at` is `null` or `undefined`,
|
|
93
|
+
`distanceInTime` returns this string without formatting -- for a scan,
|
|
94
|
+
download, or other record that doesn't have a timestamp yet.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
distanceInTime(at, relativeTo, { approximate: false }) // "15 hours ago", not "about 15 hours ago"
|
|
98
|
+
distanceInTime(null, new Date(), { whenNil: 'an unknown time' }) // "an unknown time"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`date-fns`'s `formatDistance` uses the same ActionView wording, and is the
|
|
102
|
+
better choice if you already depend on it or need months and years. It has no
|
|
103
|
+
way to drop `"about"`, phrases seconds differently (`"half a minute"`), and
|
|
104
|
+
continues past days into `"about 1 month"` and `"about 2 years"`.
|
|
105
|
+
|
|
106
|
+
## Scope
|
|
107
|
+
|
|
108
|
+
Finder's byte-count style, and a numeric (non-calendar-aware) relative time
|
|
109
|
+
style through the "1 day" bucket -- that's the whole surface area today.
|
|
110
|
+
Alternate size units/styles and a named style (`"yesterday"`,
|
|
111
|
+
calendar-boundary-aware) aren't implemented -- contributions welcome.
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
Requires Node 22.13 or later for the dev tools; the published library itself
|
|
116
|
+
runs on Node 18 and any modern browser.
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
npm install
|
|
120
|
+
make test
|
|
121
|
+
make check
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`make test` prints the full nested describe/context/it output from Vitest;
|
|
125
|
+
`make check` runs lint, the type check and the tests together, quietly unless
|
|
126
|
+
something fails.
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/size.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function humanSize(bytes: number): string;
|
package/dist/size.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const SIZE_UNITS = ['KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
|
|
2
|
+
// Lines up with SIZE_UNITS: KB 0, MB 1, GB and above 2.
|
|
3
|
+
const DECIMAL_PLACES = [0, 1, 2, 2, 2, 2];
|
|
4
|
+
export function humanSize(bytes) {
|
|
5
|
+
if (bytes === 0)
|
|
6
|
+
return 'Zero KB';
|
|
7
|
+
if (bytes < 1000)
|
|
8
|
+
return bytes === 1 ? '1 byte' : `${bytes} bytes`;
|
|
9
|
+
let exp = Math.min(Math.floor(Math.log(bytes) / Math.log(1000)), SIZE_UNITS.length);
|
|
10
|
+
let value = roundForUnit(bytes, exp);
|
|
11
|
+
if (value >= 1000 && exp < SIZE_UNITS.length) {
|
|
12
|
+
exp++;
|
|
13
|
+
value = roundForUnit(bytes, exp);
|
|
14
|
+
}
|
|
15
|
+
return `${trimZeros(value, DECIMAL_PLACES[exp - 1])} ${SIZE_UNITS[exp - 1]}`;
|
|
16
|
+
}
|
|
17
|
+
function roundForUnit(bytes, exp) {
|
|
18
|
+
const scale = 10 ** DECIMAL_PLACES[exp - 1];
|
|
19
|
+
return Math.round(bytes / 1000 ** exp * scale) / scale;
|
|
20
|
+
}
|
|
21
|
+
function trimZeros(value, decimals) {
|
|
22
|
+
const s = value.toFixed(decimals);
|
|
23
|
+
return s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s;
|
|
24
|
+
}
|
package/dist/time.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface TimeOptions {
|
|
2
|
+
approximate?: boolean;
|
|
3
|
+
includeSeconds?: boolean;
|
|
4
|
+
whenNil?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function distanceInTime(at: Date | null | undefined, relativeTo: Date, options?: TimeOptions): string;
|
|
7
|
+
export declare function timeAgo(at: Date, options?: TimeOptions): string;
|
package/dist/time.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function distanceInTime(at, relativeTo, options = {}) {
|
|
2
|
+
const { approximate = true, includeSeconds = false, whenNil = '' } = options;
|
|
3
|
+
if (at == null)
|
|
4
|
+
return whenNil;
|
|
5
|
+
let seconds = (relativeTo.getTime() - at.getTime()) / 1000;
|
|
6
|
+
const future = seconds < 0;
|
|
7
|
+
seconds = Math.abs(seconds);
|
|
8
|
+
if (!includeSeconds && seconds < 30) {
|
|
9
|
+
return future ? 'in less than a minute' : 'less than a minute ago';
|
|
10
|
+
}
|
|
11
|
+
if (includeSeconds && seconds < 60) {
|
|
12
|
+
return wrap(pluralize(Math.floor(seconds), 'second'), future);
|
|
13
|
+
}
|
|
14
|
+
// Buckets come from distanceInMinutes, not raw seconds re-divided per unit -- see docs/COMMENTS.md.
|
|
15
|
+
const distanceInMinutes = Math.floor(seconds / 60 + 0.5);
|
|
16
|
+
let text;
|
|
17
|
+
let approximable = false;
|
|
18
|
+
if (distanceInMinutes === 1) {
|
|
19
|
+
text = '1 minute';
|
|
20
|
+
}
|
|
21
|
+
else if (distanceInMinutes <= 44) {
|
|
22
|
+
text = pluralize(distanceInMinutes, 'minute');
|
|
23
|
+
}
|
|
24
|
+
else if (distanceInMinutes <= 89) {
|
|
25
|
+
text = '1 hour';
|
|
26
|
+
approximable = true;
|
|
27
|
+
}
|
|
28
|
+
else if (distanceInMinutes <= 1439) {
|
|
29
|
+
text = pluralize(Math.floor(distanceInMinutes / 60 + 0.5), 'hour');
|
|
30
|
+
approximable = true;
|
|
31
|
+
}
|
|
32
|
+
else if (distanceInMinutes <= 2519) {
|
|
33
|
+
text = '1 day';
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
text = pluralize(Math.floor(distanceInMinutes / 1440 + 0.5), 'day');
|
|
37
|
+
}
|
|
38
|
+
if (approximate && approximable)
|
|
39
|
+
text = `about ${text}`;
|
|
40
|
+
return wrap(text, future);
|
|
41
|
+
}
|
|
42
|
+
export function timeAgo(at, options = {}) {
|
|
43
|
+
return distanceInTime(at, new Date(), options);
|
|
44
|
+
}
|
|
45
|
+
function wrap(text, future) {
|
|
46
|
+
return future ? `in ${text}` : `${text} ago`;
|
|
47
|
+
}
|
|
48
|
+
function pluralize(count, unit) {
|
|
49
|
+
return count === 1 ? `1 ${unit}` : `${count} ${unit}s`;
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "humane-ts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "File sizes the way macOS Finder shows them, and relative dates in Rails ActionView's wording",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"file size",
|
|
7
|
+
"bytes",
|
|
8
|
+
"finder",
|
|
9
|
+
"relative time",
|
|
10
|
+
"time ago",
|
|
11
|
+
"actionview",
|
|
12
|
+
"distance of time in words"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "John Woodell <woodie@netpress.com>",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/woodie/humane-ts.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/woodie/humane-ts#readme",
|
|
21
|
+
"bugs": "https://github.com/woodie/humane-ts/issues",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"main": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.build.json",
|
|
40
|
+
"prepare": "npm run build",
|
|
41
|
+
"lint": "eslint . && tsc --noEmit",
|
|
42
|
+
"test": "vitest run --reporter=tree",
|
|
43
|
+
"check": "eslint . && tsc --noEmit && vitest run --reporter=dot",
|
|
44
|
+
"prepublishOnly": "npm run check && npm run build"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"eslint": "^10.12.0",
|
|
48
|
+
"neostandard": "^0.14.0",
|
|
49
|
+
"typescript": "^6.0.3",
|
|
50
|
+
"vitest": "^5.0.4"
|
|
51
|
+
}
|
|
52
|
+
}
|