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 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
- # Temporary Holding Version
1
+ # humane-ts
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![TypeScript](https://img.shields.io/badge/TypeScript-6.0-3178C6.svg?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
4
+ [![CI](https://github.com/woodie/humane-ts/actions/workflows/ci.yml/badge.svg)](https://github.com/woodie/humane-ts/actions/workflows/ci.yml)
5
+ [![Release](https://img.shields.io/github/v/release/woodie/humane-ts.svg)](https://github.com/woodie/humane-ts/releases/latest)
6
+ [![License](https://img.shields.io/github/license/woodie/humane-ts.svg)](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.
@@ -0,0 +1,3 @@
1
+ export { humanSize } from './size.js';
2
+ export { distanceInTime, timeAgo } from './time.js';
3
+ export type { TimeOptions } from './time.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { humanSize } from './size.js';
2
+ export { distanceInTime, timeAgo } from './time.js';
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }