@prairielearn/formatter 2.0.2 → 2.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @prairielearn/formatter
2
2
 
3
+ ## 2.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3d31293: Add `truncateMiddle` function that truncates a string in the middle, preserving both the start and end to maintain recognizability
8
+
9
+ ## 2.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 9c91665: Add options for full part names and first only in formatInterval
14
+ - 7b937fb: Remove unused exports, add `@knipignore` for intentionally public exports, and re-export newly used symbols from `@prairielearn/formatter`.
15
+
3
16
  ## 2.0.2
4
17
 
5
18
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
- export { formatDate, formatDateFriendly, formatDateRangeFriendly, formatDateWithinRange, formatDateYMD, formatDateYMDHM, formatTz, } from './date.js';
2
- export { formatInterval, formatIntervalHM, formatIntervalMinutes, formatIntervalRelative, makeInterval, } from './interval.js';
1
+ export { formatDate, formatDateFriendly, formatDateHMS, formatDateRangeFriendly, formatDateWithinRange, formatDateYMD, formatDateYMDHM, formatTz, } from './date.js';
2
+ export { DAY_IN_MILLISECONDS, formatInterval, formatIntervalHM, formatIntervalMinutes, formatIntervalRelative, HOUR_IN_MILLISECONDS, makeInterval, MINUTE_IN_MILLISECONDS, SECOND_IN_MILLISECONDS, } from './interval.js';
3
+ export { truncateMiddle } from './string.js';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,kBAAkB,EAClB,uBAAuB,EACvB,qBAAqB,EACrB,aAAa,EACb,eAAe,EACf,QAAQ,GACT,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,qBAAqB,EACrB,sBAAsB,EACtB,YAAY,GACb,MAAM,eAAe,CAAC","sourcesContent":["export {\n formatDate,\n formatDateFriendly,\n formatDateRangeFriendly,\n formatDateWithinRange,\n formatDateYMD,\n formatDateYMDHM,\n formatTz,\n} from './date.js';\nexport {\n formatInterval,\n formatIntervalHM,\n formatIntervalMinutes,\n formatIntervalRelative,\n makeInterval,\n} from './interval.js';\n"]}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,kBAAkB,EAClB,aAAa,EACb,uBAAuB,EACvB,qBAAqB,EACrB,aAAa,EACb,eAAe,EACf,QAAQ,GACT,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,mBAAmB,EACnB,cAAc,EACd,gBAAgB,EAChB,qBAAqB,EACrB,sBAAsB,EACtB,oBAAoB,EACpB,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC","sourcesContent":["export {\n formatDate,\n formatDateFriendly,\n formatDateHMS,\n formatDateRangeFriendly,\n formatDateWithinRange,\n formatDateYMD,\n formatDateYMDHM,\n formatTz,\n} from './date.js';\nexport {\n DAY_IN_MILLISECONDS,\n formatInterval,\n formatIntervalHM,\n formatIntervalMinutes,\n formatIntervalRelative,\n HOUR_IN_MILLISECONDS,\n makeInterval,\n MINUTE_IN_MILLISECONDS,\n SECOND_IN_MILLISECONDS,\n} from './interval.js';\nexport { truncateMiddle } from './string.js';\n"]}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
- export { formatDate, formatDateFriendly, formatDateRangeFriendly, formatDateWithinRange, formatDateYMD, formatDateYMDHM, formatTz, } from './date.js';
2
- export { formatInterval, formatIntervalHM, formatIntervalMinutes, formatIntervalRelative, makeInterval, } from './interval.js';
1
+ export { formatDate, formatDateFriendly, formatDateHMS, formatDateRangeFriendly, formatDateWithinRange, formatDateYMD, formatDateYMDHM, formatTz, } from './date.js';
2
+ export { DAY_IN_MILLISECONDS, formatInterval, formatIntervalHM, formatIntervalMinutes, formatIntervalRelative, HOUR_IN_MILLISECONDS, makeInterval, MINUTE_IN_MILLISECONDS, SECOND_IN_MILLISECONDS, } from './interval.js';
3
+ export { truncateMiddle } from './string.js';
3
4
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,kBAAkB,EAClB,uBAAuB,EACvB,qBAAqB,EACrB,aAAa,EACb,eAAe,EACf,QAAQ,GACT,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,qBAAqB,EACrB,sBAAsB,EACtB,YAAY,GACb,MAAM,eAAe,CAAC","sourcesContent":["export {\n formatDate,\n formatDateFriendly,\n formatDateRangeFriendly,\n formatDateWithinRange,\n formatDateYMD,\n formatDateYMDHM,\n formatTz,\n} from './date.js';\nexport {\n formatInterval,\n formatIntervalHM,\n formatIntervalMinutes,\n formatIntervalRelative,\n makeInterval,\n} from './interval.js';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,kBAAkB,EAClB,aAAa,EACb,uBAAuB,EACvB,qBAAqB,EACrB,aAAa,EACb,eAAe,EACf,QAAQ,GACT,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,mBAAmB,EACnB,cAAc,EACd,gBAAgB,EAChB,qBAAqB,EACrB,sBAAsB,EACtB,oBAAoB,EACpB,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC","sourcesContent":["export {\n formatDate,\n formatDateFriendly,\n formatDateHMS,\n formatDateRangeFriendly,\n formatDateWithinRange,\n formatDateYMD,\n formatDateYMDHM,\n formatTz,\n} from './date.js';\nexport {\n DAY_IN_MILLISECONDS,\n formatInterval,\n formatIntervalHM,\n formatIntervalMinutes,\n formatIntervalRelative,\n HOUR_IN_MILLISECONDS,\n makeInterval,\n MINUTE_IN_MILLISECONDS,\n SECOND_IN_MILLISECONDS,\n} from './interval.js';\nexport { truncateMiddle } from './string.js';\n"]}
@@ -17,12 +17,18 @@ export declare function makeInterval({ days, hours, minutes, seconds }: {
17
17
  seconds?: number;
18
18
  }): number;
19
19
  /**
20
- * Format an interval (in milliseconds) to a human-readable string like '3 h 40 m'.
20
+ * Format an interval (in milliseconds) to a human-readable string like '3 h 40 min'.
21
21
  *
22
22
  * @param interval Time interval in milliseconds.
23
+ * @param options
24
+ * @param options.fullPartNames Whether to use full part names (e.g. '3 hours 40 minutes' instead of '3 h 40 m'). Default is false.
25
+ * @param options.firstOnly Whether to return only the first non-zero part of the interval (e.g. '3 h' instead of '3 h 40 m'). Default is false.
23
26
  * @returns Human-readable string representing the interval.
24
27
  */
25
- export declare function formatInterval(interval: number): string;
28
+ export declare function formatInterval(interval: number, { fullPartNames, firstOnly }?: {
29
+ firstOnly?: boolean | undefined;
30
+ fullPartNames?: boolean | undefined;
31
+ }): string;
26
32
  /**
27
33
  * Format an interval (in milliseconds) to a human-readable string like 'Until 6
28
34
  * minutes before the session start time'.
@@ -1 +1 @@
1
- {"version":3,"file":"interval.d.ts","sourceRoot":"","sources":["../src/interval.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,sBAAsB,OAAO,CAAC;AAC3C,eAAO,MAAM,sBAAsB,QAA8B,CAAC;AAClE,eAAO,MAAM,oBAAoB,QAA8B,CAAC;AAChE,eAAO,MAAM,mBAAmB,QAA4B,CAAC;AAE7D;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,EAC3B,IAAQ,EACR,KAAS,EACT,OAAW,EACX,OAAW,EACZ,EAAE;IACD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,MAAM,CAET;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA2BvD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,EAC3C,SAAS,EAAE,MAAM,GAChB,MAAM,CAUR;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,EAChB,EAAE,MAAc,EAAE,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAsB,GAC3D,MAAM,CAKR;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAQ9D","sourcesContent":["export const SECOND_IN_MILLISECONDS = 1000;\nexport const MINUTE_IN_MILLISECONDS = 60 * SECOND_IN_MILLISECONDS;\nexport const HOUR_IN_MILLISECONDS = 60 * MINUTE_IN_MILLISECONDS;\nexport const DAY_IN_MILLISECONDS = 24 * HOUR_IN_MILLISECONDS;\n\n/**\n * Makes an interval (in milliseconds).\n * @param param\n * @param param.days The number of days in the interval.\n * @param param.hours The number of hours in the interval.\n * @param param.minutes The number of minutes in the interval.\n * @param param.seconds The number of seconds in the interval.\n */\nexport function makeInterval({\n days = 0,\n hours = 0,\n minutes = 0,\n seconds = 0,\n}: {\n days?: number;\n hours?: number;\n minutes?: number;\n seconds?: number;\n}): number {\n return (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;\n}\n\n/**\n * Format an interval (in milliseconds) to a human-readable string like '3 h 40 m'.\n *\n * @param interval Time interval in milliseconds.\n * @returns Human-readable string representing the interval.\n */\nexport function formatInterval(interval: number): string {\n const sign = interval < 0 ? '-' : '';\n\n const days = Math.floor(Math.abs(interval) / DAY_IN_MILLISECONDS);\n const hours = Math.floor(Math.abs(interval) / HOUR_IN_MILLISECONDS) % 24;\n const mins = Math.floor(Math.abs(interval) / MINUTE_IN_MILLISECONDS) % 60;\n const secs = Math.floor(Math.abs(interval) / SECOND_IN_MILLISECONDS) % 60;\n\n const parts: string[] = [];\n\n if (days > 0) {\n parts.push(`${sign}${days} d`);\n }\n if (hours > 0) {\n parts.push(`${sign}${hours} h`);\n }\n if (mins > 0) {\n parts.push(`${sign}${mins} min`);\n }\n if (secs > 0) {\n parts.push(`${sign}${secs} s`);\n }\n if (parts.length === 0) {\n parts.push('0 s');\n }\n\n return parts.join(' ');\n}\n\n/**\n * Format an interval (in milliseconds) to a human-readable string like 'Until 6\n * minutes before the session start time'.\n *\n * @param interval Time interval in milliseconds relative to `reference` (positive intervals are after `reference`).\n * @param prefix The prefix to use, must be 'Until' or 'From' (or lowercase versions of these).\n * @param reference The reference time, for example 'session start time'.\n * @returns Human-readable string representing the interval.\n */\nexport function formatIntervalRelative(\n interval: number,\n prefix: 'Until' | 'until' | 'From' | 'from',\n reference: string,\n): string {\n if (interval > 0) {\n return `${prefix} ${formatInterval(interval)} after ${reference}`;\n } else if (interval < 0) {\n return `${prefix} ${formatInterval(-interval)} before ${reference}`;\n } else if (interval === 0) {\n return `${prefix} ${reference}`;\n } else {\n return `Invalid interval: ${interval}`;\n }\n}\n\n/**\n * Format an interval (in milliseconds) to a human-readable string like HH:MM or +HH:MM.\n *\n * @param interval Time interval in milliseconds.\n * @param options\n * @param options.signed Whether to include the sign in the output.\n * @returns Human-readable string representing the interval in minutes.\n */\nexport function formatIntervalHM(\n interval: number,\n { signed = false }: { signed?: boolean } = { signed: false },\n): string {\n const sign = interval < 0 ? '-' : interval > 0 ? (signed ? '+' : '') : '';\n const hours = Math.floor(Math.abs(interval) / HOUR_IN_MILLISECONDS);\n const mins = Math.floor(Math.abs(interval) / MINUTE_IN_MILLISECONDS) % 60;\n return `${sign}${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;\n}\n\n/**\n * Format an interval (in milliseconds) to a human-readable string with the number of minutes, like '7 minutes' or '1 minute'.\n *\n * @param interval Time interval in milliseconds.\n * @returns Human-readable string representing the interval in minutes.\n */\nexport function formatIntervalMinutes(interval: number): string {\n const sign = interval < 0 ? '-' : '';\n const minutes = Math.ceil(Math.abs(interval / MINUTE_IN_MILLISECONDS));\n if (minutes === 1) {\n return `${sign}1 minute`;\n } else {\n return `${sign}${minutes} minutes`;\n }\n}\n"]}
1
+ {"version":3,"file":"interval.d.ts","sourceRoot":"","sources":["../src/interval.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,sBAAsB,OAAO,CAAC;AAC3C,eAAO,MAAM,sBAAsB,QAA8B,CAAC;AAClE,eAAO,MAAM,oBAAoB,QAA8B,CAAC;AAChE,eAAO,MAAM,mBAAmB,QAA4B,CAAC;AAE7D;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,EAC3B,IAAQ,EACR,KAAS,EACT,OAAW,EACX,OAAW,EACZ,EAAE;IACD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,MAAM,CAET;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,EAChB,EAAE,aAAqB,EAAE,SAAiB,EAAE;;;CAAK,GAChD,MAAM,CA2BR;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,EAC3C,SAAS,EAAE,MAAM,GAChB,MAAM,CAUR;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,EAChB,EAAE,MAAc,EAAE,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAsB,GAC3D,MAAM,CAKR;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAQ9D","sourcesContent":["export const SECOND_IN_MILLISECONDS = 1000;\nexport const MINUTE_IN_MILLISECONDS = 60 * SECOND_IN_MILLISECONDS;\nexport const HOUR_IN_MILLISECONDS = 60 * MINUTE_IN_MILLISECONDS;\nexport const DAY_IN_MILLISECONDS = 24 * HOUR_IN_MILLISECONDS;\n\n/**\n * Makes an interval (in milliseconds).\n * @param param\n * @param param.days The number of days in the interval.\n * @param param.hours The number of hours in the interval.\n * @param param.minutes The number of minutes in the interval.\n * @param param.seconds The number of seconds in the interval.\n */\nexport function makeInterval({\n days = 0,\n hours = 0,\n minutes = 0,\n seconds = 0,\n}: {\n days?: number;\n hours?: number;\n minutes?: number;\n seconds?: number;\n}): number {\n return (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;\n}\n\n/**\n * Format an interval (in milliseconds) to a human-readable string like '3 h 40 min'.\n *\n * @param interval Time interval in milliseconds.\n * @param options\n * @param options.fullPartNames Whether to use full part names (e.g. '3 hours 40 minutes' instead of '3 h 40 m'). Default is false.\n * @param options.firstOnly Whether to return only the first non-zero part of the interval (e.g. '3 h' instead of '3 h 40 m'). Default is false.\n * @returns Human-readable string representing the interval.\n */\nexport function formatInterval(\n interval: number,\n { fullPartNames = false, firstOnly = false } = {},\n): string {\n const sign = interval < 0 ? '-' : '';\n\n const days = Math.floor(Math.abs(interval) / DAY_IN_MILLISECONDS);\n const hours = Math.floor(Math.abs(interval) / HOUR_IN_MILLISECONDS) % 24;\n const mins = Math.floor(Math.abs(interval) / MINUTE_IN_MILLISECONDS) % 60;\n const secs = Math.floor(Math.abs(interval) / SECOND_IN_MILLISECONDS) % 60;\n\n const parts: string[] = [];\n\n if (days > 0) {\n parts.push(`${sign}${days} ${fullPartNames ? (days === 1 ? 'day' : 'days') : 'd'}`);\n }\n if (hours > 0) {\n parts.push(`${sign}${hours} ${fullPartNames ? (hours === 1 ? 'hour' : 'hours') : 'h'}`);\n }\n if (mins > 0) {\n parts.push(`${sign}${mins} ${fullPartNames ? (mins === 1 ? 'minute' : 'minutes') : 'min'}`);\n }\n if (secs > 0) {\n parts.push(`${sign}${secs} ${fullPartNames ? (secs === 1 ? 'second' : 'seconds') : 's'}`);\n }\n if (parts.length === 0) {\n parts.push(`0 ${fullPartNames ? 'seconds' : 's'}`);\n }\n\n return firstOnly ? parts[0] : parts.join(' ');\n}\n\n/**\n * Format an interval (in milliseconds) to a human-readable string like 'Until 6\n * minutes before the session start time'.\n *\n * @param interval Time interval in milliseconds relative to `reference` (positive intervals are after `reference`).\n * @param prefix The prefix to use, must be 'Until' or 'From' (or lowercase versions of these).\n * @param reference The reference time, for example 'session start time'.\n * @returns Human-readable string representing the interval.\n */\nexport function formatIntervalRelative(\n interval: number,\n prefix: 'Until' | 'until' | 'From' | 'from',\n reference: string,\n): string {\n if (interval > 0) {\n return `${prefix} ${formatInterval(interval)} after ${reference}`;\n } else if (interval < 0) {\n return `${prefix} ${formatInterval(-interval)} before ${reference}`;\n } else if (interval === 0) {\n return `${prefix} ${reference}`;\n } else {\n return `Invalid interval: ${interval}`;\n }\n}\n\n/**\n * Format an interval (in milliseconds) to a human-readable string like HH:MM or +HH:MM.\n *\n * @param interval Time interval in milliseconds.\n * @param options\n * @param options.signed Whether to include the sign in the output.\n * @returns Human-readable string representing the interval in minutes.\n */\nexport function formatIntervalHM(\n interval: number,\n { signed = false }: { signed?: boolean } = { signed: false },\n): string {\n const sign = interval < 0 ? '-' : interval > 0 ? (signed ? '+' : '') : '';\n const hours = Math.floor(Math.abs(interval) / HOUR_IN_MILLISECONDS);\n const mins = Math.floor(Math.abs(interval) / MINUTE_IN_MILLISECONDS) % 60;\n return `${sign}${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;\n}\n\n/**\n * Format an interval (in milliseconds) to a human-readable string with the number of minutes, like '7 minutes' or '1 minute'.\n *\n * @param interval Time interval in milliseconds.\n * @returns Human-readable string representing the interval in minutes.\n */\nexport function formatIntervalMinutes(interval: number): string {\n const sign = interval < 0 ? '-' : '';\n const minutes = Math.ceil(Math.abs(interval / MINUTE_IN_MILLISECONDS));\n if (minutes === 1) {\n return `${sign}1 minute`;\n } else {\n return `${sign}${minutes} minutes`;\n }\n}\n"]}
package/dist/interval.js CHANGED
@@ -14,12 +14,15 @@ export function makeInterval({ days = 0, hours = 0, minutes = 0, seconds = 0, })
14
14
  return (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
15
15
  }
16
16
  /**
17
- * Format an interval (in milliseconds) to a human-readable string like '3 h 40 m'.
17
+ * Format an interval (in milliseconds) to a human-readable string like '3 h 40 min'.
18
18
  *
19
19
  * @param interval Time interval in milliseconds.
20
+ * @param options
21
+ * @param options.fullPartNames Whether to use full part names (e.g. '3 hours 40 minutes' instead of '3 h 40 m'). Default is false.
22
+ * @param options.firstOnly Whether to return only the first non-zero part of the interval (e.g. '3 h' instead of '3 h 40 m'). Default is false.
20
23
  * @returns Human-readable string representing the interval.
21
24
  */
22
- export function formatInterval(interval) {
25
+ export function formatInterval(interval, { fullPartNames = false, firstOnly = false } = {}) {
23
26
  const sign = interval < 0 ? '-' : '';
24
27
  const days = Math.floor(Math.abs(interval) / DAY_IN_MILLISECONDS);
25
28
  const hours = Math.floor(Math.abs(interval) / HOUR_IN_MILLISECONDS) % 24;
@@ -27,21 +30,21 @@ export function formatInterval(interval) {
27
30
  const secs = Math.floor(Math.abs(interval) / SECOND_IN_MILLISECONDS) % 60;
28
31
  const parts = [];
29
32
  if (days > 0) {
30
- parts.push(`${sign}${days} d`);
33
+ parts.push(`${sign}${days} ${fullPartNames ? (days === 1 ? 'day' : 'days') : 'd'}`);
31
34
  }
32
35
  if (hours > 0) {
33
- parts.push(`${sign}${hours} h`);
36
+ parts.push(`${sign}${hours} ${fullPartNames ? (hours === 1 ? 'hour' : 'hours') : 'h'}`);
34
37
  }
35
38
  if (mins > 0) {
36
- parts.push(`${sign}${mins} min`);
39
+ parts.push(`${sign}${mins} ${fullPartNames ? (mins === 1 ? 'minute' : 'minutes') : 'min'}`);
37
40
  }
38
41
  if (secs > 0) {
39
- parts.push(`${sign}${secs} s`);
42
+ parts.push(`${sign}${secs} ${fullPartNames ? (secs === 1 ? 'second' : 'seconds') : 's'}`);
40
43
  }
41
44
  if (parts.length === 0) {
42
- parts.push('0 s');
45
+ parts.push(`0 ${fullPartNames ? 'seconds' : 's'}`);
43
46
  }
44
- return parts.join(' ');
47
+ return firstOnly ? parts[0] : parts.join(' ');
45
48
  }
46
49
  /**
47
50
  * Format an interval (in milliseconds) to a human-readable string like 'Until 6
@@ -1 +1 @@
1
- {"version":3,"file":"interval.js","sourceRoot":"","sources":["../src/interval.ts"],"names":[],"mappings":"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","sourcesContent":["export const SECOND_IN_MILLISECONDS = 1000;\nexport const MINUTE_IN_MILLISECONDS = 60 * SECOND_IN_MILLISECONDS;\nexport const HOUR_IN_MILLISECONDS = 60 * MINUTE_IN_MILLISECONDS;\nexport const DAY_IN_MILLISECONDS = 24 * HOUR_IN_MILLISECONDS;\n\n/**\n * Makes an interval (in milliseconds).\n * @param param\n * @param param.days The number of days in the interval.\n * @param param.hours The number of hours in the interval.\n * @param param.minutes The number of minutes in the interval.\n * @param param.seconds The number of seconds in the interval.\n */\nexport function makeInterval({\n days = 0,\n hours = 0,\n minutes = 0,\n seconds = 0,\n}: {\n days?: number;\n hours?: number;\n minutes?: number;\n seconds?: number;\n}): number {\n return (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;\n}\n\n/**\n * Format an interval (in milliseconds) to a human-readable string like '3 h 40 m'.\n *\n * @param interval Time interval in milliseconds.\n * @returns Human-readable string representing the interval.\n */\nexport function formatInterval(interval: number): string {\n const sign = interval < 0 ? '-' : '';\n\n const days = Math.floor(Math.abs(interval) / DAY_IN_MILLISECONDS);\n const hours = Math.floor(Math.abs(interval) / HOUR_IN_MILLISECONDS) % 24;\n const mins = Math.floor(Math.abs(interval) / MINUTE_IN_MILLISECONDS) % 60;\n const secs = Math.floor(Math.abs(interval) / SECOND_IN_MILLISECONDS) % 60;\n\n const parts: string[] = [];\n\n if (days > 0) {\n parts.push(`${sign}${days} d`);\n }\n if (hours > 0) {\n parts.push(`${sign}${hours} h`);\n }\n if (mins > 0) {\n parts.push(`${sign}${mins} min`);\n }\n if (secs > 0) {\n parts.push(`${sign}${secs} s`);\n }\n if (parts.length === 0) {\n parts.push('0 s');\n }\n\n return parts.join(' ');\n}\n\n/**\n * Format an interval (in milliseconds) to a human-readable string like 'Until 6\n * minutes before the session start time'.\n *\n * @param interval Time interval in milliseconds relative to `reference` (positive intervals are after `reference`).\n * @param prefix The prefix to use, must be 'Until' or 'From' (or lowercase versions of these).\n * @param reference The reference time, for example 'session start time'.\n * @returns Human-readable string representing the interval.\n */\nexport function formatIntervalRelative(\n interval: number,\n prefix: 'Until' | 'until' | 'From' | 'from',\n reference: string,\n): string {\n if (interval > 0) {\n return `${prefix} ${formatInterval(interval)} after ${reference}`;\n } else if (interval < 0) {\n return `${prefix} ${formatInterval(-interval)} before ${reference}`;\n } else if (interval === 0) {\n return `${prefix} ${reference}`;\n } else {\n return `Invalid interval: ${interval}`;\n }\n}\n\n/**\n * Format an interval (in milliseconds) to a human-readable string like HH:MM or +HH:MM.\n *\n * @param interval Time interval in milliseconds.\n * @param options\n * @param options.signed Whether to include the sign in the output.\n * @returns Human-readable string representing the interval in minutes.\n */\nexport function formatIntervalHM(\n interval: number,\n { signed = false }: { signed?: boolean } = { signed: false },\n): string {\n const sign = interval < 0 ? '-' : interval > 0 ? (signed ? '+' : '') : '';\n const hours = Math.floor(Math.abs(interval) / HOUR_IN_MILLISECONDS);\n const mins = Math.floor(Math.abs(interval) / MINUTE_IN_MILLISECONDS) % 60;\n return `${sign}${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;\n}\n\n/**\n * Format an interval (in milliseconds) to a human-readable string with the number of minutes, like '7 minutes' or '1 minute'.\n *\n * @param interval Time interval in milliseconds.\n * @returns Human-readable string representing the interval in minutes.\n */\nexport function formatIntervalMinutes(interval: number): string {\n const sign = interval < 0 ? '-' : '';\n const minutes = Math.ceil(Math.abs(interval / MINUTE_IN_MILLISECONDS));\n if (minutes === 1) {\n return `${sign}1 minute`;\n } else {\n return `${sign}${minutes} minutes`;\n }\n}\n"]}
1
+ {"version":3,"file":"interval.js","sourceRoot":"","sources":["../src/interval.ts"],"names":[],"mappings":"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","sourcesContent":["export const SECOND_IN_MILLISECONDS = 1000;\nexport const MINUTE_IN_MILLISECONDS = 60 * SECOND_IN_MILLISECONDS;\nexport const HOUR_IN_MILLISECONDS = 60 * MINUTE_IN_MILLISECONDS;\nexport const DAY_IN_MILLISECONDS = 24 * HOUR_IN_MILLISECONDS;\n\n/**\n * Makes an interval (in milliseconds).\n * @param param\n * @param param.days The number of days in the interval.\n * @param param.hours The number of hours in the interval.\n * @param param.minutes The number of minutes in the interval.\n * @param param.seconds The number of seconds in the interval.\n */\nexport function makeInterval({\n days = 0,\n hours = 0,\n minutes = 0,\n seconds = 0,\n}: {\n days?: number;\n hours?: number;\n minutes?: number;\n seconds?: number;\n}): number {\n return (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;\n}\n\n/**\n * Format an interval (in milliseconds) to a human-readable string like '3 h 40 min'.\n *\n * @param interval Time interval in milliseconds.\n * @param options\n * @param options.fullPartNames Whether to use full part names (e.g. '3 hours 40 minutes' instead of '3 h 40 m'). Default is false.\n * @param options.firstOnly Whether to return only the first non-zero part of the interval (e.g. '3 h' instead of '3 h 40 m'). Default is false.\n * @returns Human-readable string representing the interval.\n */\nexport function formatInterval(\n interval: number,\n { fullPartNames = false, firstOnly = false } = {},\n): string {\n const sign = interval < 0 ? '-' : '';\n\n const days = Math.floor(Math.abs(interval) / DAY_IN_MILLISECONDS);\n const hours = Math.floor(Math.abs(interval) / HOUR_IN_MILLISECONDS) % 24;\n const mins = Math.floor(Math.abs(interval) / MINUTE_IN_MILLISECONDS) % 60;\n const secs = Math.floor(Math.abs(interval) / SECOND_IN_MILLISECONDS) % 60;\n\n const parts: string[] = [];\n\n if (days > 0) {\n parts.push(`${sign}${days} ${fullPartNames ? (days === 1 ? 'day' : 'days') : 'd'}`);\n }\n if (hours > 0) {\n parts.push(`${sign}${hours} ${fullPartNames ? (hours === 1 ? 'hour' : 'hours') : 'h'}`);\n }\n if (mins > 0) {\n parts.push(`${sign}${mins} ${fullPartNames ? (mins === 1 ? 'minute' : 'minutes') : 'min'}`);\n }\n if (secs > 0) {\n parts.push(`${sign}${secs} ${fullPartNames ? (secs === 1 ? 'second' : 'seconds') : 's'}`);\n }\n if (parts.length === 0) {\n parts.push(`0 ${fullPartNames ? 'seconds' : 's'}`);\n }\n\n return firstOnly ? parts[0] : parts.join(' ');\n}\n\n/**\n * Format an interval (in milliseconds) to a human-readable string like 'Until 6\n * minutes before the session start time'.\n *\n * @param interval Time interval in milliseconds relative to `reference` (positive intervals are after `reference`).\n * @param prefix The prefix to use, must be 'Until' or 'From' (or lowercase versions of these).\n * @param reference The reference time, for example 'session start time'.\n * @returns Human-readable string representing the interval.\n */\nexport function formatIntervalRelative(\n interval: number,\n prefix: 'Until' | 'until' | 'From' | 'from',\n reference: string,\n): string {\n if (interval > 0) {\n return `${prefix} ${formatInterval(interval)} after ${reference}`;\n } else if (interval < 0) {\n return `${prefix} ${formatInterval(-interval)} before ${reference}`;\n } else if (interval === 0) {\n return `${prefix} ${reference}`;\n } else {\n return `Invalid interval: ${interval}`;\n }\n}\n\n/**\n * Format an interval (in milliseconds) to a human-readable string like HH:MM or +HH:MM.\n *\n * @param interval Time interval in milliseconds.\n * @param options\n * @param options.signed Whether to include the sign in the output.\n * @returns Human-readable string representing the interval in minutes.\n */\nexport function formatIntervalHM(\n interval: number,\n { signed = false }: { signed?: boolean } = { signed: false },\n): string {\n const sign = interval < 0 ? '-' : interval > 0 ? (signed ? '+' : '') : '';\n const hours = Math.floor(Math.abs(interval) / HOUR_IN_MILLISECONDS);\n const mins = Math.floor(Math.abs(interval) / MINUTE_IN_MILLISECONDS) % 60;\n return `${sign}${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;\n}\n\n/**\n * Format an interval (in milliseconds) to a human-readable string with the number of minutes, like '7 minutes' or '1 minute'.\n *\n * @param interval Time interval in milliseconds.\n * @returns Human-readable string representing the interval in minutes.\n */\nexport function formatIntervalMinutes(interval: number): string {\n const sign = interval < 0 ? '-' : '';\n const minutes = Math.ceil(Math.abs(interval / MINUTE_IN_MILLISECONDS));\n if (minutes === 1) {\n return `${sign}1 minute`;\n } else {\n return `${sign}${minutes} minutes`;\n }\n}\n"]}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Truncates a string in the middle, preserving both the start and end to
3
+ * maintain recognizability. Uses a 60/40 split favoring the start.
4
+ * For example, "CS 101 Proficiency Exam" at maxLength 16 becomes
5
+ * "CS 101 P... Exam".
6
+ */
7
+ export declare function truncateMiddle(str: string, maxLength: number): string;
8
+ //# sourceMappingURL=string.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"string.d.ts","sourceRoot":"","sources":["../src/string.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAYrE","sourcesContent":["/**\n * Truncates a string in the middle, preserving both the start and end to\n * maintain recognizability. Uses a 60/40 split favoring the start.\n * For example, \"CS 101 Proficiency Exam\" at maxLength 16 becomes\n * \"CS 101 P... Exam\".\n */\nexport function truncateMiddle(str: string, maxLength: number): string {\n if (str.length <= maxLength) {\n return str;\n }\n const ellipsis = '...';\n const remaining = Math.max(0, maxLength - ellipsis.length);\n const startLength = Math.ceil(remaining * 0.6);\n const endLength = remaining - startLength;\n if (endLength <= 0) {\n return str.slice(0, remaining) + ellipsis;\n }\n return str.slice(0, startLength) + ellipsis + str.slice(str.length - endLength);\n}\n"]}
package/dist/string.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Truncates a string in the middle, preserving both the start and end to
3
+ * maintain recognizability. Uses a 60/40 split favoring the start.
4
+ * For example, "CS 101 Proficiency Exam" at maxLength 16 becomes
5
+ * "CS 101 P... Exam".
6
+ */
7
+ export function truncateMiddle(str, maxLength) {
8
+ if (str.length <= maxLength) {
9
+ return str;
10
+ }
11
+ const ellipsis = '...';
12
+ const remaining = Math.max(0, maxLength - ellipsis.length);
13
+ const startLength = Math.ceil(remaining * 0.6);
14
+ const endLength = remaining - startLength;
15
+ if (endLength <= 0) {
16
+ return str.slice(0, remaining) + ellipsis;
17
+ }
18
+ return str.slice(0, startLength) + ellipsis + str.slice(str.length - endLength);
19
+ }
20
+ //# sourceMappingURL=string.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"string.js","sourceRoot":"","sources":["../src/string.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,GAAW,EAAE,SAAiB,EAAU;IACrE,IAAI,GAAG,CAAC,MAAM,IAAI,SAAS,EAAE,CAAC;QAC5B,OAAO,GAAG,CAAC;IACb,CAAC;IACD,MAAM,QAAQ,GAAG,KAAK,CAAC;IACvB,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC3D,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC;IAC/C,MAAM,SAAS,GAAG,SAAS,GAAG,WAAW,CAAC;IAC1C,IAAI,SAAS,IAAI,CAAC,EAAE,CAAC;QACnB,OAAO,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAC;IAC5C,CAAC;IACD,OAAO,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,WAAW,CAAC,GAAG,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;AAAA,CACjF","sourcesContent":["/**\n * Truncates a string in the middle, preserving both the start and end to\n * maintain recognizability. Uses a 60/40 split favoring the start.\n * For example, \"CS 101 Proficiency Exam\" at maxLength 16 becomes\n * \"CS 101 P... Exam\".\n */\nexport function truncateMiddle(str: string, maxLength: number): string {\n if (str.length <= maxLength) {\n return str;\n }\n const ellipsis = '...';\n const remaining = Math.max(0, maxLength - ellipsis.length);\n const startLength = Math.ceil(remaining * 0.6);\n const endLength = remaining - startLength;\n if (endLength <= 0) {\n return str.slice(0, remaining) + ellipsis;\n }\n return str.slice(0, startLength) + ellipsis + str.slice(str.length - endLength);\n}\n"]}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=string.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"string.test.d.ts","sourceRoot":"","sources":["../src/string.test.ts"],"names":[],"mappings":"","sourcesContent":["import { assert, describe, it } from 'vitest';\n\nimport { truncateMiddle } from './string.js';\n\ndescribe('truncateMiddle', () => {\n it('should not truncate a string that fits within maxLength', () => {\n assert.equal(truncateMiddle('ECE-GY 6913', 20), 'ECE-GY 6913');\n });\n\n it('should not truncate a string that exactly matches maxLength', () => {\n assert.equal(truncateMiddle('ECE-GY 6913', 11), 'ECE-GY 6913');\n });\n\n it('should truncate in the middle preserving start and end', () => {\n assert.equal(truncateMiddle('Introduction to Computing', 20), 'Introductio...puting');\n });\n\n it('should favor the start of the string', () => {\n assert.equal(truncateMiddle('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 15), 'ABCDEFGH...WXYZ');\n });\n\n it('should handle realistic course names at limit 16', () => {\n assert.equal(truncateMiddle('CS 101 Proficiency Exam', 16), 'CS 101 P... Exam');\n });\n\n it('should handle realistic term that is one char too long', () => {\n assert.equal(truncateMiddle('Spring 2023', 10), 'Sprin...23');\n });\n\n it('should handle a very short maxLength', () => {\n assert.equal(truncateMiddle('hello world', 4), 'h...');\n });\n\n it('should handle maxLength of 3', () => {\n assert.equal(truncateMiddle('hello world', 3), '...');\n });\n\n it('should handle an empty string', () => {\n assert.equal(truncateMiddle('', 10), '');\n });\n});\n"]}
@@ -0,0 +1,32 @@
1
+ import { assert, describe, it } from 'vitest';
2
+ import { truncateMiddle } from './string.js';
3
+ describe('truncateMiddle', () => {
4
+ it('should not truncate a string that fits within maxLength', () => {
5
+ assert.equal(truncateMiddle('ECE-GY 6913', 20), 'ECE-GY 6913');
6
+ });
7
+ it('should not truncate a string that exactly matches maxLength', () => {
8
+ assert.equal(truncateMiddle('ECE-GY 6913', 11), 'ECE-GY 6913');
9
+ });
10
+ it('should truncate in the middle preserving start and end', () => {
11
+ assert.equal(truncateMiddle('Introduction to Computing', 20), 'Introductio...puting');
12
+ });
13
+ it('should favor the start of the string', () => {
14
+ assert.equal(truncateMiddle('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 15), 'ABCDEFGH...WXYZ');
15
+ });
16
+ it('should handle realistic course names at limit 16', () => {
17
+ assert.equal(truncateMiddle('CS 101 Proficiency Exam', 16), 'CS 101 P... Exam');
18
+ });
19
+ it('should handle realistic term that is one char too long', () => {
20
+ assert.equal(truncateMiddle('Spring 2023', 10), 'Sprin...23');
21
+ });
22
+ it('should handle a very short maxLength', () => {
23
+ assert.equal(truncateMiddle('hello world', 4), 'h...');
24
+ });
25
+ it('should handle maxLength of 3', () => {
26
+ assert.equal(truncateMiddle('hello world', 3), '...');
27
+ });
28
+ it('should handle an empty string', () => {
29
+ assert.equal(truncateMiddle('', 10), '');
30
+ });
31
+ });
32
+ //# sourceMappingURL=string.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"string.test.js","sourceRoot":"","sources":["../src/string.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAE9C,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE7C,QAAQ,CAAC,gBAAgB,EAAE,GAAG,EAAE,CAAC;IAC/B,EAAE,CAAC,yDAAyD,EAAE,GAAG,EAAE,CAAC;QAClE,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,aAAa,EAAE,EAAE,CAAC,EAAE,aAAa,CAAC,CAAC;IAAA,CAChE,CAAC,CAAC;IAEH,EAAE,CAAC,6DAA6D,EAAE,GAAG,EAAE,CAAC;QACtE,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,aAAa,EAAE,EAAE,CAAC,EAAE,aAAa,CAAC,CAAC;IAAA,CAChE,CAAC,CAAC;IAEH,EAAE,CAAC,wDAAwD,EAAE,GAAG,EAAE,CAAC;QACjE,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,2BAA2B,EAAE,EAAE,CAAC,EAAE,sBAAsB,CAAC,CAAC;IAAA,CACvF,CAAC,CAAC;IAEH,EAAE,CAAC,sCAAsC,EAAE,GAAG,EAAE,CAAC;QAC/C,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,4BAA4B,EAAE,EAAE,CAAC,EAAE,iBAAiB,CAAC,CAAC;IAAA,CACnF,CAAC,CAAC;IAEH,EAAE,CAAC,kDAAkD,EAAE,GAAG,EAAE,CAAC;QAC3D,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,yBAAyB,EAAE,EAAE,CAAC,EAAE,kBAAkB,CAAC,CAAC;IAAA,CACjF,CAAC,CAAC;IAEH,EAAE,CAAC,wDAAwD,EAAE,GAAG,EAAE,CAAC;QACjE,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,aAAa,EAAE,EAAE,CAAC,EAAE,YAAY,CAAC,CAAC;IAAA,CAC/D,CAAC,CAAC;IAEH,EAAE,CAAC,sCAAsC,EAAE,GAAG,EAAE,CAAC;QAC/C,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,aAAa,EAAE,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;IAAA,CACxD,CAAC,CAAC;IAEH,EAAE,CAAC,8BAA8B,EAAE,GAAG,EAAE,CAAC;QACvC,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,aAAa,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;IAAA,CACvD,CAAC,CAAC;IAEH,EAAE,CAAC,+BAA+B,EAAE,GAAG,EAAE,CAAC;QACxC,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;IAAA,CAC1C,CAAC,CAAC;AAAA,CACJ,CAAC,CAAC","sourcesContent":["import { assert, describe, it } from 'vitest';\n\nimport { truncateMiddle } from './string.js';\n\ndescribe('truncateMiddle', () => {\n it('should not truncate a string that fits within maxLength', () => {\n assert.equal(truncateMiddle('ECE-GY 6913', 20), 'ECE-GY 6913');\n });\n\n it('should not truncate a string that exactly matches maxLength', () => {\n assert.equal(truncateMiddle('ECE-GY 6913', 11), 'ECE-GY 6913');\n });\n\n it('should truncate in the middle preserving start and end', () => {\n assert.equal(truncateMiddle('Introduction to Computing', 20), 'Introductio...puting');\n });\n\n it('should favor the start of the string', () => {\n assert.equal(truncateMiddle('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 15), 'ABCDEFGH...WXYZ');\n });\n\n it('should handle realistic course names at limit 16', () => {\n assert.equal(truncateMiddle('CS 101 Proficiency Exam', 16), 'CS 101 P... Exam');\n });\n\n it('should handle realistic term that is one char too long', () => {\n assert.equal(truncateMiddle('Spring 2023', 10), 'Sprin...23');\n });\n\n it('should handle a very short maxLength', () => {\n assert.equal(truncateMiddle('hello world', 4), 'h...');\n });\n\n it('should handle maxLength of 3', () => {\n assert.equal(truncateMiddle('hello world', 3), '...');\n });\n\n it('should handle an empty string', () => {\n assert.equal(truncateMiddle('', 10), '');\n });\n});\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prairielearn/formatter",
3
- "version": "2.0.2",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,7 +23,7 @@
23
23
  "devDependencies": {
24
24
  "@prairielearn/tsconfig": "^2.0.0",
25
25
  "@types/node": "^24.10.9",
26
- "@typescript/native-preview": "^7.0.0-dev.20260130.1",
26
+ "@typescript/native-preview": "^7.0.0-dev.20260203.1",
27
27
  "@vitest/coverage-v8": "^4.0.18",
28
28
  "tsx": "^4.21.0",
29
29
  "typescript": "^5.9.3",
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  formatDate,
3
3
  formatDateFriendly,
4
+ formatDateHMS,
4
5
  formatDateRangeFriendly,
5
6
  formatDateWithinRange,
6
7
  formatDateYMD,
@@ -8,9 +9,14 @@ export {
8
9
  formatTz,
9
10
  } from './date.js';
10
11
  export {
12
+ DAY_IN_MILLISECONDS,
11
13
  formatInterval,
12
14
  formatIntervalHM,
13
15
  formatIntervalMinutes,
14
16
  formatIntervalRelative,
17
+ HOUR_IN_MILLISECONDS,
15
18
  makeInterval,
19
+ MINUTE_IN_MILLISECONDS,
20
+ SECOND_IN_MILLISECONDS,
16
21
  } from './interval.js';
22
+ export { truncateMiddle } from './string.js';
package/src/interval.ts CHANGED
@@ -26,12 +26,18 @@ export function makeInterval({
26
26
  }
27
27
 
28
28
  /**
29
- * Format an interval (in milliseconds) to a human-readable string like '3 h 40 m'.
29
+ * Format an interval (in milliseconds) to a human-readable string like '3 h 40 min'.
30
30
  *
31
31
  * @param interval Time interval in milliseconds.
32
+ * @param options
33
+ * @param options.fullPartNames Whether to use full part names (e.g. '3 hours 40 minutes' instead of '3 h 40 m'). Default is false.
34
+ * @param options.firstOnly Whether to return only the first non-zero part of the interval (e.g. '3 h' instead of '3 h 40 m'). Default is false.
32
35
  * @returns Human-readable string representing the interval.
33
36
  */
34
- export function formatInterval(interval: number): string {
37
+ export function formatInterval(
38
+ interval: number,
39
+ { fullPartNames = false, firstOnly = false } = {},
40
+ ): string {
35
41
  const sign = interval < 0 ? '-' : '';
36
42
 
37
43
  const days = Math.floor(Math.abs(interval) / DAY_IN_MILLISECONDS);
@@ -42,22 +48,22 @@ export function formatInterval(interval: number): string {
42
48
  const parts: string[] = [];
43
49
 
44
50
  if (days > 0) {
45
- parts.push(`${sign}${days} d`);
51
+ parts.push(`${sign}${days} ${fullPartNames ? (days === 1 ? 'day' : 'days') : 'd'}`);
46
52
  }
47
53
  if (hours > 0) {
48
- parts.push(`${sign}${hours} h`);
54
+ parts.push(`${sign}${hours} ${fullPartNames ? (hours === 1 ? 'hour' : 'hours') : 'h'}`);
49
55
  }
50
56
  if (mins > 0) {
51
- parts.push(`${sign}${mins} min`);
57
+ parts.push(`${sign}${mins} ${fullPartNames ? (mins === 1 ? 'minute' : 'minutes') : 'min'}`);
52
58
  }
53
59
  if (secs > 0) {
54
- parts.push(`${sign}${secs} s`);
60
+ parts.push(`${sign}${secs} ${fullPartNames ? (secs === 1 ? 'second' : 'seconds') : 's'}`);
55
61
  }
56
62
  if (parts.length === 0) {
57
- parts.push('0 s');
63
+ parts.push(`0 ${fullPartNames ? 'seconds' : 's'}`);
58
64
  }
59
65
 
60
- return parts.join(' ');
66
+ return firstOnly ? parts[0] : parts.join(' ');
61
67
  }
62
68
 
63
69
  /**
@@ -0,0 +1,41 @@
1
+ import { assert, describe, it } from 'vitest';
2
+
3
+ import { truncateMiddle } from './string.js';
4
+
5
+ describe('truncateMiddle', () => {
6
+ it('should not truncate a string that fits within maxLength', () => {
7
+ assert.equal(truncateMiddle('ECE-GY 6913', 20), 'ECE-GY 6913');
8
+ });
9
+
10
+ it('should not truncate a string that exactly matches maxLength', () => {
11
+ assert.equal(truncateMiddle('ECE-GY 6913', 11), 'ECE-GY 6913');
12
+ });
13
+
14
+ it('should truncate in the middle preserving start and end', () => {
15
+ assert.equal(truncateMiddle('Introduction to Computing', 20), 'Introductio...puting');
16
+ });
17
+
18
+ it('should favor the start of the string', () => {
19
+ assert.equal(truncateMiddle('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 15), 'ABCDEFGH...WXYZ');
20
+ });
21
+
22
+ it('should handle realistic course names at limit 16', () => {
23
+ assert.equal(truncateMiddle('CS 101 Proficiency Exam', 16), 'CS 101 P... Exam');
24
+ });
25
+
26
+ it('should handle realistic term that is one char too long', () => {
27
+ assert.equal(truncateMiddle('Spring 2023', 10), 'Sprin...23');
28
+ });
29
+
30
+ it('should handle a very short maxLength', () => {
31
+ assert.equal(truncateMiddle('hello world', 4), 'h...');
32
+ });
33
+
34
+ it('should handle maxLength of 3', () => {
35
+ assert.equal(truncateMiddle('hello world', 3), '...');
36
+ });
37
+
38
+ it('should handle an empty string', () => {
39
+ assert.equal(truncateMiddle('', 10), '');
40
+ });
41
+ });
package/src/string.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Truncates a string in the middle, preserving both the start and end to
3
+ * maintain recognizability. Uses a 60/40 split favoring the start.
4
+ * For example, "CS 101 Proficiency Exam" at maxLength 16 becomes
5
+ * "CS 101 P... Exam".
6
+ */
7
+ export function truncateMiddle(str: string, maxLength: number): string {
8
+ if (str.length <= maxLength) {
9
+ return str;
10
+ }
11
+ const ellipsis = '...';
12
+ const remaining = Math.max(0, maxLength - ellipsis.length);
13
+ const startLength = Math.ceil(remaining * 0.6);
14
+ const endLength = remaining - startLength;
15
+ if (endLength <= 0) {
16
+ return str.slice(0, remaining) + ellipsis;
17
+ }
18
+ return str.slice(0, startLength) + ellipsis + str.slice(str.length - endLength);
19
+ }