JavaScript-optional public web frontend for Bluesky
anartia.kelinci.net
sveltekit
atcute
bluesky
typescript
svelte
1// @ts-check
2
3let startOfYear = 0;
4let endOfYear = 0;
5
6const fmtTime = new Intl.DateTimeFormat('en-US', {
7 timeStyle: 'short',
8});
9const fmtDateTime = new Intl.DateTimeFormat('en-US', {
10 dateStyle: 'long',
11 timeStyle: 'short',
12});
13const fmtShortDateWithYear = new Intl.DateTimeFormat('en-US', {
14 dateStyle: 'medium',
15});
16const fmtShortDate = new Intl.DateTimeFormat('en-US', {
17 month: 'short',
18 day: 'numeric',
19});
20
21/**
22 * @param {string | number} date
23 * @returns {string}
24 */
25const formatShortDate = (date) => {
26 const inst = new Date(date);
27 const time = inst.getTime();
28
29 if (Number.isNaN(time)) {
30 return 'N/A';
31 }
32
33 const now = Date.now();
34 if (now > endOfYear) {
35 const date = new Date(now);
36
37 date.setMonth(0, 1);
38 date.setHours(0, 0, 0);
39 startOfYear = date.getTime();
40
41 date.setFullYear(date.getFullYear() + 1, 0, 0);
42 date.setHours(23, 59, 59, 999);
43 endOfYear = date.getTime();
44 }
45
46 if (time >= startOfYear && time <= endOfYear) {
47 return fmtShortDate.format(inst);
48 }
49
50 return fmtShortDateWithYear.format(inst);
51};
52
53/**
54 * @param {string | number} date
55 * @returns {string}
56 */
57const formatTime = (date) => {
58 const inst = new Date(date);
59
60 if (Number.isNaN(inst.getTime())) {
61 return 'N/A';
62 }
63
64 return fmtTime.format(inst);
65};
66
67/**
68 * @param {string | number} date
69 * @returns {string}
70 */
71const formatLongDate = (date) => {
72 const inst = new Date(date);
73
74 if (Number.isNaN(inst.getTime())) {
75 return 'N/A';
76 }
77
78 return fmtDateTime.format(inst);
79};
80
81/** @type {Record<string, Intl.NumberFormat>} */
82const relativeFormatters = {};
83
84const SECOND = 1e3;
85const NOW = SECOND * 10;
86const MINUTE = SECOND * 60;
87const HOUR = MINUTE * 60;
88const DAY = HOUR * 24;
89const WEEK = DAY * 7;
90
91/**
92 * @param {string | number} date
93 * @param {number} now
94 * @returns {string}
95 */
96const formatRelativeTime = (date, now) => {
97 const time = new Date(date).getTime();
98
99 if (Number.isNaN(time)) {
100 return 'N/A';
101 }
102
103 const delta = now - time;
104
105 if (delta < -NOW || delta > WEEK) {
106 if (now > endOfYear) {
107 const date = new Date();
108
109 date.setMonth(0, 1);
110 date.setHours(0, 0, 0);
111 startOfYear = date.getTime();
112
113 date.setFullYear(date.getFullYear() + 1, 0, 0);
114 date.setHours(23, 59, 59, 999);
115 endOfYear = date.getTime();
116 }
117
118 // if it happened this year, don't show the year.
119 if (time >= startOfYear && time <= endOfYear) {
120 return fmtShortDate.format(time);
121 }
122
123 return fmtShortDateWithYear.format(time);
124 }
125
126 if (delta < NOW) {
127 return `now`;
128 }
129
130 {
131 /** @type {number} */
132 let value;
133 /** @type {Intl.RelativeTimeFormatUnit} */
134 let unit;
135
136 if (delta < MINUTE) {
137 value = Math.floor(delta / SECOND);
138 unit = 'second';
139 } else if (delta < HOUR) {
140 value = Math.floor(delta / MINUTE);
141 unit = 'minute';
142 } else if (delta < DAY) {
143 value = Math.floor(delta / HOUR);
144 unit = 'hour';
145 } else {
146 // use rounding, this handles the following scenario:
147 // - 2024-02-13T09:00Z <- 2024-02-15T07:00Z = 2d
148 value = Math.round(delta / DAY);
149 unit = 'day';
150 }
151
152 const formatter = (relativeFormatters[unit] ||= new Intl.NumberFormat('en-US', {
153 style: 'unit',
154 unit: unit,
155 unitDisplay: 'narrow',
156 }));
157
158 return formatter.format(Math.abs(value));
159 }
160};
161
162(() => {
163 /** @type {NodeListOf<HTMLTimeElement>} */
164 const nodes = document.querySelectorAll('time[data-format="short-date"]');
165 if (nodes.length === 0) {
166 return;
167 }
168
169 for (const node of nodes) {
170 const dt = node.dateTime;
171
172 node.textContent = formatShortDate(dt);
173 node.title = formatLongDate(dt);
174 }
175})();
176
177(() => {
178 /** @type {NodeListOf<HTMLTimeElement>} */
179 const nodes = document.querySelectorAll('time[data-format="long-date"]');
180 if (nodes.length === 0) {
181 return;
182 }
183
184 for (const node of nodes) {
185 node.textContent = formatLongDate(node.dateTime);
186 }
187})();
188
189(() => {
190 /** @type {NodeListOf<HTMLTimeElement>} */
191 const nodes = document.querySelectorAll('time[data-format="time"]');
192 if (nodes.length === 0) {
193 return;
194 }
195
196 for (const node of nodes) {
197 const dt = node.dateTime;
198
199 node.textContent = formatTime(dt);
200 node.title = formatLongDate(dt);
201 }
202})();
203
204(() => {
205 /** @type {NodeListOf<HTMLTimeElement>} */
206 const nodes = document.querySelectorAll('time[data-format="relative-time"]');
207 if (nodes.length === 0) {
208 return;
209 }
210
211 const update = () => {
212 const now = Date.now();
213
214 for (const node of nodes) {
215 const dt = node.dateTime;
216
217 node.textContent = formatRelativeTime(dt, now);
218 node.title = formatLongDate(dt);
219 }
220 };
221
222 update();
223 setInterval(update, 60_000);
224})();