@prairielearn/formatter 1.3.14 → 1.4.1
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/CHANGELOG.md +12 -0
- package/dist/date.d.ts +70 -0
- package/dist/date.js +257 -0
- package/dist/date.js.map +1 -1
- package/dist/date.test.js +238 -1
- package/dist/date.test.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/interval.d.ts +41 -0
- package/dist/interval.js +63 -0
- package/dist/interval.js.map +1 -1
- package/dist/interval.test.js +66 -1
- package/dist/interval.test.js.map +1 -1
- package/package.json +6 -5
- package/src/date.test.ts +349 -1
- package/src/date.ts +326 -0
- package/src/index.ts +16 -2
- package/src/interval.test.ts +81 -1
- package/src/interval.ts +80 -0
package/src/date.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toTemporalInstant } from '@js-temporal/polyfill';
|
|
1
2
|
import keyBy from 'lodash/keyBy.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -96,3 +97,328 @@ export function formatTz(timeZone: string): string {
|
|
|
96
97
|
const tz = parts.find((p) => p.type === 'timeZoneName');
|
|
97
98
|
return tz ? tz.value : timeZone;
|
|
98
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Format a date to a human-readable string like '14:27:00 (CDT)'.
|
|
103
|
+
*
|
|
104
|
+
* @param date The date to format.
|
|
105
|
+
* @param timeZone The time zone to use for formatting.
|
|
106
|
+
* @param param2.includeTz Whether to include the time zone in the output (default true).
|
|
107
|
+
* @returns Human-readable string representing the date.
|
|
108
|
+
*/
|
|
109
|
+
export function formatDateHMS(
|
|
110
|
+
date: Date,
|
|
111
|
+
timeZone: string,
|
|
112
|
+
{ includeTz = true }: { includeTz?: boolean } = {},
|
|
113
|
+
): string {
|
|
114
|
+
const options: Intl.DateTimeFormatOptions = {
|
|
115
|
+
timeZone,
|
|
116
|
+
hourCycle: 'h23',
|
|
117
|
+
hour: '2-digit',
|
|
118
|
+
minute: '2-digit',
|
|
119
|
+
second: '2-digit',
|
|
120
|
+
timeZoneName: 'short',
|
|
121
|
+
};
|
|
122
|
+
const parts = keyBy(new Intl.DateTimeFormat('en-US', options).formatToParts(date), (x) => x.type);
|
|
123
|
+
let dateFormatted = `${parts.hour.value}:${parts.minute.value}:${parts.second.value}`;
|
|
124
|
+
if (includeTz) {
|
|
125
|
+
dateFormatted = `${dateFormatted} (${parts.timeZoneName.value})`;
|
|
126
|
+
}
|
|
127
|
+
return dateFormatted;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Format a date to a human-readable string like '18:23' or 'May 2, 07:12',
|
|
132
|
+
* where the precision is determined by the range.
|
|
133
|
+
*
|
|
134
|
+
* @param date The date to format.
|
|
135
|
+
* @param rangeStart The start of the range.
|
|
136
|
+
* @param rangeEnd The end of the range.
|
|
137
|
+
* @param timeZone The time zone to use for formatting.
|
|
138
|
+
* @returns Human-readable string representing the date.
|
|
139
|
+
*/
|
|
140
|
+
export function formatDateWithinRange(
|
|
141
|
+
date: Date,
|
|
142
|
+
rangeStart: Date,
|
|
143
|
+
rangeEnd: Date,
|
|
144
|
+
timeZone: string,
|
|
145
|
+
): string {
|
|
146
|
+
const options: Intl.DateTimeFormatOptions = {
|
|
147
|
+
timeZone,
|
|
148
|
+
hourCycle: 'h23',
|
|
149
|
+
year: 'numeric',
|
|
150
|
+
month: '2-digit',
|
|
151
|
+
day: '2-digit',
|
|
152
|
+
hour: '2-digit',
|
|
153
|
+
minute: '2-digit',
|
|
154
|
+
timeZoneName: 'short',
|
|
155
|
+
};
|
|
156
|
+
const dateParts = keyBy(
|
|
157
|
+
new Intl.DateTimeFormat('en-US', options).formatToParts(date),
|
|
158
|
+
(x) => x.type,
|
|
159
|
+
);
|
|
160
|
+
const startParts = keyBy(
|
|
161
|
+
new Intl.DateTimeFormat('en-US', options).formatToParts(rangeStart),
|
|
162
|
+
(x) => x.type,
|
|
163
|
+
);
|
|
164
|
+
const endParts = keyBy(
|
|
165
|
+
new Intl.DateTimeFormat('en-US', options).formatToParts(rangeEnd),
|
|
166
|
+
(x) => x.type,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// format the date (not time) parts
|
|
170
|
+
const dateYMD = `${dateParts.year.value}-${dateParts.month.value}-${dateParts.day.value}`;
|
|
171
|
+
const startYMD = `${startParts.year.value}-${startParts.month.value}-${startParts.day.value}`;
|
|
172
|
+
const endYMD = `${endParts.year.value}-${endParts.month.value}-${endParts.day.value}`;
|
|
173
|
+
|
|
174
|
+
if (dateYMD === startYMD && dateYMD === endYMD) {
|
|
175
|
+
// only show the time if the date is the same for all three
|
|
176
|
+
return `${dateParts.hour.value}:${dateParts.minute.value}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// format the year, but not the month or day
|
|
180
|
+
const dateY = `${dateParts.year.value}`;
|
|
181
|
+
const startY = `${startParts.year.value}`;
|
|
182
|
+
const endY = `${endParts.year.value}`;
|
|
183
|
+
|
|
184
|
+
// if the year is the same for all three, show the month, day, and time
|
|
185
|
+
if (dateY === startY && dateY === endY) {
|
|
186
|
+
const options: Intl.DateTimeFormatOptions = {
|
|
187
|
+
timeZone,
|
|
188
|
+
month: 'short',
|
|
189
|
+
day: 'numeric',
|
|
190
|
+
hour: '2-digit',
|
|
191
|
+
minute: '2-digit',
|
|
192
|
+
};
|
|
193
|
+
const dateParts = keyBy(
|
|
194
|
+
new Intl.DateTimeFormat('en-US', options).formatToParts(date),
|
|
195
|
+
(x) => x.type,
|
|
196
|
+
);
|
|
197
|
+
return `${dateParts.month.value} ${dateParts.day.value}, ${dateParts.hour.value}:${dateParts.minute.value}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// fall back to the full date
|
|
201
|
+
return `${dateParts.year.value}-${dateParts.month.value}-${dateParts.day.value} ${dateParts.hour.value}:${dateParts.minute.value}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Format a Date to date and time strings in the given time zone. The date is
|
|
206
|
+
* formatted like
|
|
207
|
+
* - 'today'
|
|
208
|
+
* - 'Mon, Mar 20' (if within 180 days of the base date)
|
|
209
|
+
* - 'Wed, Jan 1, 2020'
|
|
210
|
+
*
|
|
211
|
+
* The time format leaves off zero minutes and seconds, and uses 12-hour time,
|
|
212
|
+
* giving strings like
|
|
213
|
+
* - '3pm'
|
|
214
|
+
* - '3:34pm'
|
|
215
|
+
* - '3:34:17pm'
|
|
216
|
+
*
|
|
217
|
+
* @param date The date to format.
|
|
218
|
+
* @param timezone The time zone to use for formatting.
|
|
219
|
+
* @param baseDate The base date to use for comparison.
|
|
220
|
+
*/
|
|
221
|
+
function formatDateFriendlyParts(
|
|
222
|
+
date: Date,
|
|
223
|
+
timezone: string,
|
|
224
|
+
baseDate: Date,
|
|
225
|
+
): { dateFormatted: string; timeFormatted: string; timezoneFormatted: string } {
|
|
226
|
+
// compute the number of days from the base date (0 = today, 1 = tomorrow, etc.)
|
|
227
|
+
|
|
228
|
+
const baseZonedDateTime = toTemporalInstant.call(baseDate).toZonedDateTimeISO(timezone);
|
|
229
|
+
const zonedDateTime = toTemporalInstant.call(date).toZonedDateTimeISO(timezone);
|
|
230
|
+
|
|
231
|
+
const basePlainDate = baseZonedDateTime.toPlainDate();
|
|
232
|
+
const plainDate = zonedDateTime.toPlainDate();
|
|
233
|
+
|
|
234
|
+
const daysOffset = plainDate.since(basePlainDate, { largestUnit: 'day' }).days;
|
|
235
|
+
|
|
236
|
+
// format the parts of the date and time
|
|
237
|
+
|
|
238
|
+
const options: Intl.DateTimeFormatOptions = {
|
|
239
|
+
timeZone: timezone,
|
|
240
|
+
year: 'numeric',
|
|
241
|
+
month: 'short',
|
|
242
|
+
day: 'numeric',
|
|
243
|
+
weekday: 'short',
|
|
244
|
+
hourCycle: 'h12',
|
|
245
|
+
hour: 'numeric',
|
|
246
|
+
minute: '2-digit',
|
|
247
|
+
second: '2-digit',
|
|
248
|
+
timeZoneName: 'short',
|
|
249
|
+
};
|
|
250
|
+
const parts = keyBy(new Intl.DateTimeFormat('en-US', options).formatToParts(date), (x) => x.type);
|
|
251
|
+
|
|
252
|
+
// format the date string
|
|
253
|
+
|
|
254
|
+
let dateFormatted = '';
|
|
255
|
+
if (daysOffset === 0) {
|
|
256
|
+
dateFormatted = 'today';
|
|
257
|
+
} else if (daysOffset === 1) {
|
|
258
|
+
dateFormatted = 'tomorrow';
|
|
259
|
+
} else if (daysOffset === -1) {
|
|
260
|
+
dateFormatted = 'yesterday';
|
|
261
|
+
} else if (Math.abs(daysOffset) <= 180) {
|
|
262
|
+
// non-breaking-space (\u00a0) is used between the month and day
|
|
263
|
+
dateFormatted = `${parts.weekday.value}, ${parts.month.value}\u00a0${parts.day.value}`;
|
|
264
|
+
} else {
|
|
265
|
+
dateFormatted = `${parts.weekday.value}, ${parts.month.value}\u00a0${parts.day.value}, ${parts.year.value}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// format the time string
|
|
269
|
+
|
|
270
|
+
let timeFormatted = '';
|
|
271
|
+
if (parts.minute.value === '00' && parts.second.value === '00') {
|
|
272
|
+
timeFormatted = `${parts.hour.value}`;
|
|
273
|
+
} else if (parts.second.value === '00') {
|
|
274
|
+
timeFormatted = `${parts.hour.value}:${parts.minute.value}`;
|
|
275
|
+
} else {
|
|
276
|
+
timeFormatted = `${parts.hour.value}:${parts.minute.value}:${parts.second.value}`;
|
|
277
|
+
}
|
|
278
|
+
// add the am/pm part
|
|
279
|
+
timeFormatted = `${timeFormatted}${parts.dayPeriod.value.toLowerCase()}`;
|
|
280
|
+
|
|
281
|
+
// format the timezone
|
|
282
|
+
|
|
283
|
+
const timezoneFormatted = `(${parts.timeZoneName.value})`;
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
dateFormatted,
|
|
287
|
+
timeFormatted,
|
|
288
|
+
timezoneFormatted,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Format a date to a string like:
|
|
294
|
+
* - 'today, 3pm'
|
|
295
|
+
* - 'tomorrow, 10:30am'
|
|
296
|
+
* - 'yesterday, 11:45pm'
|
|
297
|
+
* - 'Mon, Mar 20, 8:15am' (if within 180 days of the base date)
|
|
298
|
+
* - 'Wed, Jan 1, 2020, 12pm'
|
|
299
|
+
* - `today, 3pm (CDT)` (if `includeTz` is true)
|
|
300
|
+
* - `3pm today` (if `timeFirst` is true)
|
|
301
|
+
* - 'today' (if `dateOnly` is true)
|
|
302
|
+
*
|
|
303
|
+
* If using this within a sentence like `... at ${formatDateFriendlyString()}`,
|
|
304
|
+
* use `timeFirst: true` to improve readability.
|
|
305
|
+
*
|
|
306
|
+
* @param date The date to format.
|
|
307
|
+
* @param timezone The time zone to use for formatting.
|
|
308
|
+
* @param param.baseDate The base date to use for comparison (default is the current date).
|
|
309
|
+
* @param param.includeTz Whether to include the time zone in the output (default true).
|
|
310
|
+
* @param param.timeFirst If true, the time is shown before the date (default false).
|
|
311
|
+
* @param param.dateOnly If true, only the date is shown (default false).
|
|
312
|
+
* @param param.timeOnly If true, only the time is shown (default false).
|
|
313
|
+
* @returns Human-readable string representing the date and time.
|
|
314
|
+
*/
|
|
315
|
+
export function formatDateFriendly(
|
|
316
|
+
date: Date,
|
|
317
|
+
timezone: string,
|
|
318
|
+
{
|
|
319
|
+
baseDate = new Date(),
|
|
320
|
+
includeTz = true,
|
|
321
|
+
timeFirst = false,
|
|
322
|
+
dateOnly = false,
|
|
323
|
+
timeOnly = false,
|
|
324
|
+
}: {
|
|
325
|
+
baseDate?: Date;
|
|
326
|
+
includeTz?: boolean;
|
|
327
|
+
timeFirst?: boolean;
|
|
328
|
+
dateOnly?: boolean;
|
|
329
|
+
timeOnly?: boolean;
|
|
330
|
+
} = {},
|
|
331
|
+
): string {
|
|
332
|
+
const { dateFormatted, timeFormatted, timezoneFormatted } = formatDateFriendlyParts(
|
|
333
|
+
date,
|
|
334
|
+
timezone,
|
|
335
|
+
baseDate,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
let dateTimeFormatted = '';
|
|
339
|
+
if (dateOnly) {
|
|
340
|
+
dateTimeFormatted = dateFormatted;
|
|
341
|
+
} else if (timeOnly) {
|
|
342
|
+
dateTimeFormatted = timeFormatted;
|
|
343
|
+
} else {
|
|
344
|
+
if (timeFirst) {
|
|
345
|
+
dateTimeFormatted = `${timeFormatted} ${dateFormatted}`;
|
|
346
|
+
} else {
|
|
347
|
+
dateTimeFormatted = `${dateFormatted}, ${timeFormatted}`;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (includeTz) {
|
|
351
|
+
dateTimeFormatted = `${dateTimeFormatted} ${timezoneFormatted}`;
|
|
352
|
+
}
|
|
353
|
+
return dateTimeFormatted;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Format a datetime range to a string like:
|
|
358
|
+
* - 'today, 10am'
|
|
359
|
+
* - 'today, 3pm to 5pm'
|
|
360
|
+
* - 'today, 3pm to tomorrow, 5pm'
|
|
361
|
+
* - 'today, 3pm to 5pm (CDT)' (if `includeTz` is true)
|
|
362
|
+
* - '3pm today to 5pm tomorrow' (if `timeFirst` is true)
|
|
363
|
+
* - 'today to tomorrow' (if `dateOnly` is true)
|
|
364
|
+
*
|
|
365
|
+
* This uses `formatDateFriendlyString()` to format the individual dates and times.
|
|
366
|
+
*
|
|
367
|
+
* @param start The start date and time.
|
|
368
|
+
* @param end The end date and time.
|
|
369
|
+
* @param timezone The time zone to use for formatting.
|
|
370
|
+
* @param options Additional options for formatting the displayed date, taken from `formatDateFriendlyString()`.
|
|
371
|
+
* @returns Human-readable string representing the datetime range.
|
|
372
|
+
*/
|
|
373
|
+
export function formatDateRangeFriendly(
|
|
374
|
+
start: Date,
|
|
375
|
+
end: Date,
|
|
376
|
+
timezone: string,
|
|
377
|
+
{
|
|
378
|
+
baseDate = new Date(),
|
|
379
|
+
includeTz = true,
|
|
380
|
+
timeFirst = false,
|
|
381
|
+
dateOnly = false,
|
|
382
|
+
}: Parameters<typeof formatDateFriendly>[2] = {},
|
|
383
|
+
): string {
|
|
384
|
+
const {
|
|
385
|
+
dateFormatted: startDateFormatted,
|
|
386
|
+
timeFormatted: startTimeFormatted,
|
|
387
|
+
timezoneFormatted,
|
|
388
|
+
} = formatDateFriendlyParts(start, timezone, baseDate);
|
|
389
|
+
const { dateFormatted: endDateFormatted, timeFormatted: endTimeFormatted } =
|
|
390
|
+
formatDateFriendlyParts(end, timezone, baseDate);
|
|
391
|
+
|
|
392
|
+
let result: string | undefined;
|
|
393
|
+
if (dateOnly) {
|
|
394
|
+
if (startDateFormatted === endDateFormatted) {
|
|
395
|
+
result = startDateFormatted;
|
|
396
|
+
} else {
|
|
397
|
+
result = `${startDateFormatted} to ${endDateFormatted}`;
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
if (startDateFormatted === endDateFormatted) {
|
|
401
|
+
let timeRangeFormatted: string | undefined;
|
|
402
|
+
if (startTimeFormatted === endTimeFormatted) {
|
|
403
|
+
timeRangeFormatted = startTimeFormatted;
|
|
404
|
+
} else {
|
|
405
|
+
timeRangeFormatted = `${startTimeFormatted} to ${endTimeFormatted}`;
|
|
406
|
+
}
|
|
407
|
+
if (timeFirst) {
|
|
408
|
+
result = `${timeRangeFormatted} ${startDateFormatted}`;
|
|
409
|
+
} else {
|
|
410
|
+
result = `${startDateFormatted}, ${timeRangeFormatted}`;
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
if (timeFirst) {
|
|
414
|
+
result = `${startTimeFormatted} ${startDateFormatted} to ${endTimeFormatted} ${endDateFormatted}`;
|
|
415
|
+
} else {
|
|
416
|
+
result = `${startDateFormatted}, ${startTimeFormatted} to ${endDateFormatted}, ${endTimeFormatted}`;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (includeTz) {
|
|
421
|
+
result = `${result} ${timezoneFormatted}`;
|
|
422
|
+
}
|
|
423
|
+
return result;
|
|
424
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,16 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
1
|
+
export {
|
|
2
|
+
formatDate,
|
|
3
|
+
formatDateFriendly,
|
|
4
|
+
formatDateRangeFriendly,
|
|
5
|
+
formatDateWithinRange,
|
|
6
|
+
formatDateYMD,
|
|
7
|
+
formatDateYMDHM,
|
|
8
|
+
formatTz,
|
|
9
|
+
} from './date.js';
|
|
10
|
+
export {
|
|
11
|
+
formatInterval,
|
|
12
|
+
formatIntervalHM,
|
|
13
|
+
formatIntervalMinutes,
|
|
14
|
+
formatIntervalRelative,
|
|
15
|
+
makeInterval,
|
|
16
|
+
} from './interval.js';
|
package/src/interval.test.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { assert, describe, it } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
formatInterval,
|
|
5
|
+
formatIntervalHM,
|
|
6
|
+
formatIntervalMinutes,
|
|
7
|
+
formatIntervalRelative,
|
|
8
|
+
makeInterval,
|
|
9
|
+
} from './interval.js';
|
|
4
10
|
|
|
5
11
|
describe('interval formatting', () => {
|
|
6
12
|
describe('formatInterval()', () => {
|
|
@@ -32,4 +38,78 @@ describe('interval formatting', () => {
|
|
|
32
38
|
);
|
|
33
39
|
});
|
|
34
40
|
});
|
|
41
|
+
describe('formatIntervalRelative()', () => {
|
|
42
|
+
it('should handle positive intervals', () => {
|
|
43
|
+
assert.equal(
|
|
44
|
+
formatIntervalRelative(3 * 1000, 'Until', 'the start time'),
|
|
45
|
+
'Until 3 s after the start time',
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
it('should handle negative intervals', () => {
|
|
49
|
+
assert.equal(
|
|
50
|
+
formatIntervalRelative(-7 * 60 * 1000, 'From', 'the start time'),
|
|
51
|
+
'From 7 min before the start time',
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
it('should handle zero intervals', () => {
|
|
55
|
+
assert.equal(formatIntervalRelative(0, 'From', 'the start time'), 'From the start time');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('formatIntervalMinutes()', () => {
|
|
60
|
+
it('should correctly round up', () => {
|
|
61
|
+
assert.equal(formatIntervalMinutes(3.2 * 60 * 1000), '4 minutes');
|
|
62
|
+
});
|
|
63
|
+
it('should correctly handle 1 minute', () => {
|
|
64
|
+
assert.equal(formatIntervalMinutes(17 * 1000), '1 minute');
|
|
65
|
+
});
|
|
66
|
+
it('should correctly handle zero', () => {
|
|
67
|
+
assert.equal(formatIntervalMinutes(0), '0 minutes');
|
|
68
|
+
});
|
|
69
|
+
it('should correctly handle -1 minute', () => {
|
|
70
|
+
assert.equal(formatIntervalMinutes(-17 * 1000), '-1 minute');
|
|
71
|
+
});
|
|
72
|
+
it('should correctly handle negative intervals', () => {
|
|
73
|
+
assert.equal(formatIntervalMinutes(-3.2 * 60 * 1000), '-4 minutes');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('formatIntervalHM()', () => {
|
|
78
|
+
it('should correctly handle postive minutes', () => {
|
|
79
|
+
assert.equal(formatIntervalHM(3.2 * 60 * 1000), '00:03');
|
|
80
|
+
});
|
|
81
|
+
it('should correctly handle postive hours', () => {
|
|
82
|
+
assert.equal(formatIntervalHM((4 * 60 + 17.8) * 60 * 1000), '04:17');
|
|
83
|
+
});
|
|
84
|
+
it('should correctly handle large postive hours', () => {
|
|
85
|
+
assert.equal(formatIntervalHM((143 * 60 + 17.8) * 60 * 1000), '143:17');
|
|
86
|
+
});
|
|
87
|
+
it('should correctly handle an explicit sign', () => {
|
|
88
|
+
assert.equal(formatIntervalHM((4 * 60 + 17.8) * 60 * 1000, { signed: true }), '+04:17');
|
|
89
|
+
});
|
|
90
|
+
it('should correctly handle negative minutes', () => {
|
|
91
|
+
assert.equal(formatIntervalHM(-3.2 * 60 * 1000), '-00:03');
|
|
92
|
+
});
|
|
93
|
+
it('should correctly handle negative hours', () => {
|
|
94
|
+
assert.equal(formatIntervalHM(-(4 * 60 + 17.8) * 60 * 1000), '-04:17');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('makeInterval()', () => {
|
|
99
|
+
it('should handle seconds', () => {
|
|
100
|
+
assert.equal(makeInterval({ seconds: 7 }), 7 * 1000);
|
|
101
|
+
});
|
|
102
|
+
it('should handle minutes', () => {
|
|
103
|
+
assert.equal(makeInterval({ minutes: 2 }), 2 * 60 * 1000);
|
|
104
|
+
});
|
|
105
|
+
it('should handle hours', () => {
|
|
106
|
+
assert.equal(makeInterval({ hours: 3 }), 3 * 60 * 60 * 1000);
|
|
107
|
+
});
|
|
108
|
+
it('should handle days', () => {
|
|
109
|
+
assert.equal(makeInterval({ days: 4 }), 4 * 24 * 60 * 60 * 1000);
|
|
110
|
+
});
|
|
111
|
+
it('should default to zero', () => {
|
|
112
|
+
assert.equal(makeInterval({}), 0);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
35
115
|
});
|
package/src/interval.ts
CHANGED
|
@@ -3,6 +3,28 @@ export const MINUTE_IN_MILLISECONDS = 60 * SECOND_IN_MILLISECONDS;
|
|
|
3
3
|
export const HOUR_IN_MILLISECONDS = 60 * MINUTE_IN_MILLISECONDS;
|
|
4
4
|
export const DAY_IN_MILLISECONDS = 24 * HOUR_IN_MILLISECONDS;
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Makes an interval (in milliseconds).
|
|
8
|
+
*
|
|
9
|
+
* @param param.days The number of days in the interval.
|
|
10
|
+
* @param param.hours The number of hours in the interval.
|
|
11
|
+
* @param param.minutes The number of minutes in the interval.
|
|
12
|
+
* @param param.seconds The number of seconds in the interval.
|
|
13
|
+
*/
|
|
14
|
+
export function makeInterval({
|
|
15
|
+
days = 0,
|
|
16
|
+
hours = 0,
|
|
17
|
+
minutes = 0,
|
|
18
|
+
seconds = 0,
|
|
19
|
+
}: {
|
|
20
|
+
days?: number;
|
|
21
|
+
hours?: number;
|
|
22
|
+
minutes?: number;
|
|
23
|
+
seconds?: number;
|
|
24
|
+
}): number {
|
|
25
|
+
return (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
|
|
26
|
+
}
|
|
27
|
+
|
|
6
28
|
/**
|
|
7
29
|
* Format an interval (in milliseconds) to a human-readable string like '3 h 40 m'.
|
|
8
30
|
*
|
|
@@ -37,3 +59,61 @@ export function formatInterval(interval: number): string {
|
|
|
37
59
|
|
|
38
60
|
return parts.join(' ');
|
|
39
61
|
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Format an interval (in milliseconds) to a human-readable string like 'Until 6
|
|
65
|
+
* minutes before the session start time'.
|
|
66
|
+
*
|
|
67
|
+
* @param interval Time interval in milliseconds relative to `reference` (positive intervals are after `reference`).
|
|
68
|
+
* @param prefix The prefix to use, must be 'Until' or 'From' (or lowercase versions of these).
|
|
69
|
+
* @param reference The reference time, for example 'session start time'.
|
|
70
|
+
* @returns Human-readable string representing the interval.
|
|
71
|
+
*/
|
|
72
|
+
export function formatIntervalRelative(
|
|
73
|
+
interval: number,
|
|
74
|
+
prefix: 'Until' | 'until' | 'From' | 'from',
|
|
75
|
+
reference: string,
|
|
76
|
+
): string {
|
|
77
|
+
if (interval > 0) {
|
|
78
|
+
return `${prefix} ${formatInterval(interval)} after ${reference}`;
|
|
79
|
+
} else if (interval < 0) {
|
|
80
|
+
return `${prefix} ${formatInterval(-interval)} before ${reference}`;
|
|
81
|
+
} else if (interval === 0) {
|
|
82
|
+
return `${prefix} ${reference}`;
|
|
83
|
+
} else {
|
|
84
|
+
return `Invalid interval: ${interval}`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Format an interval (in milliseconds) to a human-readable string like HH:MM or +HH:MM.
|
|
90
|
+
*
|
|
91
|
+
* @param interval Time interval in milliseconds.
|
|
92
|
+
* @param options.signed Whether to include the sign in the output.
|
|
93
|
+
* @returns Human-readable string representing the interval in minutes.
|
|
94
|
+
*/
|
|
95
|
+
export function formatIntervalHM(
|
|
96
|
+
interval: number,
|
|
97
|
+
{ signed = false }: { signed?: boolean } = { signed: false },
|
|
98
|
+
): string {
|
|
99
|
+
const sign = interval < 0 ? '-' : interval > 0 ? (signed ? '+' : '') : '';
|
|
100
|
+
const hours = Math.floor(Math.abs(interval) / HOUR_IN_MILLISECONDS);
|
|
101
|
+
const mins = Math.floor(Math.abs(interval) / MINUTE_IN_MILLISECONDS) % 60;
|
|
102
|
+
return `${sign}${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Format an interval (in milliseconds) to a human-readable string with the number of minutes, like '7 minutes' or '1 minute'.
|
|
107
|
+
*
|
|
108
|
+
* @param interval Time interval in milliseconds.
|
|
109
|
+
* @returns Human-readable string representing the interval in minutes.
|
|
110
|
+
*/
|
|
111
|
+
export function formatIntervalMinutes(interval: number): string {
|
|
112
|
+
const sign = interval < 0 ? '-' : '';
|
|
113
|
+
const minutes = Math.ceil(Math.abs(interval / MINUTE_IN_MILLISECONDS));
|
|
114
|
+
if (minutes === 1) {
|
|
115
|
+
return `${sign}1 minute`;
|
|
116
|
+
} else {
|
|
117
|
+
return `${sign}${minutes} minutes`;
|
|
118
|
+
}
|
|
119
|
+
}
|