Skip to content

Commit 86e7191

Browse files
committed
feat: initial release
0 parents  commit 86e7191

12 files changed

Lines changed: 903 additions & 0 deletions

File tree

.github/banner.png

680 KB
Loading

.github/workflows/main.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: ci
2+
on:
3+
push:
4+
branches: [main]
5+
pull_request:
6+
branches: [main]
7+
jobs:
8+
build:
9+
runs-on: ubuntu-latest
10+
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: oven-sh/setup-bun@v2
14+
15+
- name: Install dependencies (bun)
16+
run: bun install
17+
- name: Lint (Biome)
18+
run: bun lint
19+
- name: Run TypeScript (tsc)
20+
run: bun typecheck
21+
- name: Test
22+
run: bun test
23+
- name: Build
24+
run: bun run build

.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# dependencies (bun install)
2+
node_modules
3+
4+
# output
5+
dist
6+
7+
# code coverage
8+
coverage
9+
*.lcov
10+
11+
# finder (macos) folder config
12+
.DS_Store

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) Matt Simpson (https://github.com/msmps)
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.

README.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<img alt="banner" src="https://github.com/msmps/little-timestamp/raw/main/.github/banner.png"/>
2+
3+
<div align="center">
4+
<img src="https://badgen.net/npm/v/little-timestamp?" alt="NPM Version" />
5+
<img src="https://github.com/msmps/little-timestamp/workflows/ci/badge.svg" alt="Build Status" />
6+
</div>
7+
8+
<br />
9+
10+
<div align="center"><strong>A small & opinionated timestamp formatting library</strong></div>
11+
<div align="center">
12+
<sub>Built by <a href="https://x.com/msmps_">Matt Simpson</a> | Inspired by <a href="https://x.com/steventey/status/1928487987211600104">Steven Tey</a> & <a href="https://x.com/timolins/status/1825856000844509570">Timo Lins</a></sub>
13+
</div>
14+
15+
<br />
16+
17+
## Usage
18+
19+
```js
20+
import { formatTimestamp } from "little-timestamp";
21+
22+
const today = new Date("2025-06-02T12:00:00.000Z"); // For testing purposes
23+
const timestamp = new Date("2025-06-02T06:00:00.000Z"); // 6am
24+
25+
console.log(formatTimestamp(timestamp, { today })); // Outputs: "6h ago"
26+
```
27+
28+
## Installation
29+
30+
#### With Bun
31+
32+
```sh
33+
bun add little-timestamp
34+
```
35+
36+
#### With NPM
37+
38+
```sh
39+
npm i little-timestamp
40+
```
41+
42+
## Formatting Examples
43+
44+
| Description | Output |
45+
| ------------------- | --------------- |
46+
| **Past** | |
47+
| <1s | `Just now` |
48+
| 1-59s | `"N"s ago` |
49+
| 1-59m | `"N"m ago` |
50+
| 1-23h | `"N"h ago` |
51+
| >24h | `Jun 1` |
52+
| >24h different year | `Jun 1, 2024` |
53+
| **Future** | |
54+
| 1-59s | `"N"s from now` |
55+
| 1-59m | `"N"m from now` |
56+
| 1-23h | `"N"h from now` |
57+
| >24h | `Jun 1` |
58+
| >24h different year | `Jun 1, 2026` |
59+
60+
## Advanced Options
61+
62+
Most of the formatting behavior is opinionated and can't be changed. However, there are some options that can be used to customize the output.
63+
64+
```js
65+
import { formatTimestamp } from "little-timestamp";
66+
67+
// ...
68+
69+
formatTimestamp(timestamp, {
70+
locale: "es-ES", // Overwrite the default date locale
71+
today: new Date(), // Overwrite the default "today" date, useful for testing purposes
72+
});
73+
```
74+
75+
## Contribute
76+
77+
We welcome contributions! If you'd like to improve `little-timestamp` or have any feedback, feel free to open an issue or submit a pull request.
78+
79+
## License
80+
81+
MIT

biome.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3+
"vcs": {
4+
"enabled": true,
5+
"clientKind": "git",
6+
"useIgnoreFile": true
7+
},
8+
"formatter": {
9+
"indentStyle": "space",
10+
"indentWidth": 2
11+
}
12+
}

bun.lock

Lines changed: 550 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"name": "little-timestamp",
3+
"version": "1.0.0",
4+
"description": "A small & opinionated timestamp formatting library",
5+
"author": "Matt Simpson",
6+
"license": "MIT",
7+
"repository": "msmps/little-timestamp",
8+
"main": "./dist/cjs/index.js",
9+
"module": "./dist/es/index.mjs",
10+
"types": "./dist/cjs/index.d.ts",
11+
"exports": {
12+
".": {
13+
"import": {
14+
"types": "./dist/es/index.d.mts",
15+
"default": "./dist/es/index.mjs"
16+
},
17+
"require": {
18+
"types": "./dist/cjs/index.d.ts",
19+
"default": "./dist/cjs/index.js"
20+
}
21+
}
22+
},
23+
"engines": {
24+
"node": ">=16.0.0"
25+
},
26+
"files": ["dist", "src"],
27+
"scripts": {
28+
"build": "bunchee",
29+
"dev": "TZ=UTC vitest",
30+
"test": "TZ=UTC vitest run",
31+
"typecheck": "tsc --noEmit",
32+
"lint": "biome check .",
33+
"lint:fix": "biome check --write .",
34+
"test:coverage": "TZ=UTC vitest run --coverage"
35+
},
36+
"keywords": ["timestamp", "date", "time", "short"],
37+
"devDependencies": {
38+
"@biomejs/biome": "^1.9.4",
39+
"@vitest/coverage-v8": "^3.2.0",
40+
"bunchee": "^6.5.2",
41+
"typescript": "^5.8.3",
42+
"vitest": "^3.2.0"
43+
},
44+
"sideEffects": false
45+
}

src/format-timestamp.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
type FormatTimestampOptions,
4+
formatTimestamp,
5+
} from "./format-timestamp";
6+
7+
const today = new Date("2025-06-02T12:00:00.000Z");
8+
9+
const defaultOptions: FormatTimestampOptions = {
10+
today,
11+
locale: "en-US",
12+
};
13+
14+
describe("format timestamps", () => {
15+
describe("past timestamps", () => {
16+
it("format just now", () => {
17+
const timestamp = new Date("2025-06-02T11:59:59.500Z");
18+
expect(formatTimestamp(timestamp, defaultOptions)).toBe("Just now");
19+
});
20+
21+
it("format seconds ago", () => {
22+
const timestamp = new Date("2025-06-02T11:59:30.000Z");
23+
expect(formatTimestamp(timestamp, defaultOptions)).toBe("30s ago");
24+
});
25+
26+
it("format minutes ago", () => {
27+
const timestamp = new Date("2025-06-02T11:30:00.000Z");
28+
expect(formatTimestamp(timestamp, defaultOptions)).toBe("30m ago");
29+
});
30+
31+
it("format hours ago", () => {
32+
const timestamp = new Date("2025-06-02T09:00:00.000Z");
33+
expect(formatTimestamp(timestamp, defaultOptions)).toBe("3h ago");
34+
});
35+
36+
it("format this year", () => {
37+
const timestamp = new Date("2025-06-01T12:00:00.000Z");
38+
expect(formatTimestamp(timestamp, defaultOptions)).toBe("Jun 1");
39+
});
40+
41+
it("format last year", () => {
42+
const timestamp = new Date("2024-06-01T12:00:00.000Z");
43+
expect(formatTimestamp(timestamp, defaultOptions)).toBe("Jun 1, 2024");
44+
});
45+
});
46+
47+
describe("future timestamps", () => {
48+
it("format just now", () => {
49+
const timestamp = new Date("2025-06-02T12:00:00.000Z");
50+
expect(formatTimestamp(timestamp, defaultOptions)).toBe("Just now");
51+
});
52+
53+
it("format seconds from now", () => {
54+
const timestamp = new Date("2025-06-02T12:00:30.000Z");
55+
expect(formatTimestamp(timestamp, defaultOptions)).toBe("30s from now");
56+
});
57+
58+
it("format minutes from now", () => {
59+
const timestamp = new Date("2025-06-02T12:30:00.000Z");
60+
expect(formatTimestamp(timestamp, defaultOptions)).toBe("30m from now");
61+
});
62+
63+
it("format hours from now", () => {
64+
const timestamp = new Date("2025-06-02T15:00:00.000Z");
65+
expect(formatTimestamp(timestamp, defaultOptions)).toBe("3h from now");
66+
});
67+
68+
it("format this year", () => {
69+
const timestamp = new Date("2025-06-03T12:00:00.000Z");
70+
expect(formatTimestamp(timestamp, defaultOptions)).toBe("Jun 3");
71+
});
72+
73+
it("format last year", () => {
74+
const timestamp = new Date("2024-06-03T12:00:00.000Z");
75+
expect(formatTimestamp(timestamp, defaultOptions)).toBe("Jun 3, 2024");
76+
});
77+
});
78+
79+
describe("custom options", () => {
80+
it("format with custom locale", () => {
81+
const timestamp = new Date("2025-06-01T12:00:00.000Z");
82+
const options: FormatTimestampOptions = {
83+
...defaultOptions,
84+
locale: "en-GB",
85+
};
86+
expect(formatTimestamp(timestamp, options)).toBe("1 Jun");
87+
});
88+
89+
it("automatic locale detection", () => {
90+
const timestamp = new Date("2025-06-01T12:00:00.000Z");
91+
expect(
92+
formatTimestamp(timestamp, {
93+
today,
94+
}),
95+
).toBe("Jun 1");
96+
});
97+
});
98+
});

src/format-timestamp.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
const getNavigatorLocale = () => {
2+
if (typeof window === "undefined") {
3+
return "en-US";
4+
}
5+
6+
return window.navigator.language;
7+
};
8+
9+
export interface FormatTimestampOptions {
10+
today?: Date;
11+
locale?: string;
12+
}
13+
14+
export function formatTimestamp(
15+
date: Date,
16+
{
17+
today = new Date(),
18+
locale = getNavigatorLocale(),
19+
}: FormatTimestampOptions = {},
20+
) {
21+
const distance = today.getTime() - date.getTime();
22+
const suffix = distance < 0 ? "from now" : "ago";
23+
const absoluteDistance = Math.abs(distance);
24+
25+
const seconds = Math.floor(absoluteDistance / 1000);
26+
const minutes = Math.floor(absoluteDistance / 60000);
27+
const hours = Math.floor(absoluteDistance / 3600000);
28+
29+
let display: string;
30+
31+
if (seconds < 1) {
32+
display = "Just now";
33+
} else if (seconds < 60) {
34+
display = `${seconds}s ${suffix}`;
35+
} else if (minutes < 60) {
36+
display = `${minutes}m ${suffix}`;
37+
} else if (hours < 24) {
38+
display = `${hours}h ${suffix}`;
39+
} else {
40+
const isThisYear = today.getFullYear() === date.getFullYear();
41+
display = new Intl.DateTimeFormat(locale, {
42+
month: "short",
43+
day: "numeric",
44+
...(isThisYear ? {} : { year: "numeric" }),
45+
}).format(date);
46+
}
47+
48+
return display;
49+
}

0 commit comments

Comments
 (0)