@dxos/util 0.8.4-main.fdfb99ef29 → 0.8.4-staging.60fe92afc8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/util",
3
- "version": "0.8.4-main.fdfb99ef29",
3
+ "version": "0.8.4-staging.60fe92afc8",
4
4
  "description": "Temporary bucket for misc functions, which should graduate into separate packages.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -8,12 +8,13 @@
8
8
  "type": "git",
9
9
  "url": "https://github.com/dxos/dxos"
10
10
  },
11
- "license": "MIT",
11
+ "license": "FSL-1.1-Apache-2.0",
12
12
  "author": "DXOS.org",
13
13
  "sideEffects": false,
14
14
  "type": "module",
15
15
  "exports": {
16
16
  ".": {
17
+ "source": "./src/index.ts",
17
18
  "types": "./dist/types/src/index.d.ts",
18
19
  "browser": "./dist/lib/browser/index.mjs",
19
20
  "node": {
@@ -29,13 +30,13 @@
29
30
  ],
30
31
  "dependencies": {
31
32
  "@hazae41/symbol-dispose-polyfill": "^1.0.2",
32
- "@dxos/invariant": "0.8.4-main.fdfb99ef29",
33
- "@dxos/debug": "0.8.4-main.fdfb99ef29",
34
- "@dxos/keys": "0.8.4-main.fdfb99ef29",
35
- "@dxos/node-std": "0.8.4-main.fdfb99ef29"
33
+ "@dxos/debug": "0.8.4-staging.60fe92afc8",
34
+ "@dxos/invariant": "0.8.4-staging.60fe92afc8",
35
+ "@dxos/node-std": "0.8.4-staging.60fe92afc8",
36
+ "@dxos/keys": "0.8.4-staging.60fe92afc8"
36
37
  },
37
38
  "devDependencies": {
38
- "@dxos/crypto": "0.8.4-main.fdfb99ef29"
39
+ "@dxos/crypto": "0.8.4-staging.60fe92afc8"
39
40
  },
40
41
  "publishConfig": {
41
42
  "access": "public"
package/src/id.test.ts ADDED
@@ -0,0 +1,109 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { id, isWellFormedId, isWellFormedIdPart } from './id';
8
+
9
+ describe('id', () => {
10
+ test('accepts a three-part id', ({ expect }) => {
11
+ expect(id`com.example.foo`).toBe('com.example.foo');
12
+ });
13
+
14
+ test('accepts a four-part id', ({ expect }) => {
15
+ expect(id`org.dxos.plugin.deck`).toBe('org.dxos.plugin.deck');
16
+ });
17
+
18
+ test('accepts hyphens in authority segments', ({ expect }) => {
19
+ expect(id`my-org.example.foo`).toBe('my-org.example.foo');
20
+ });
21
+
22
+ test('accepts mixed-case name segment', ({ expect }) => {
23
+ expect(id`org.dxos.plugin.deckPlugin`).toBe('org.dxos.plugin.deckPlugin');
24
+ });
25
+
26
+ test('accepts alphanumeric characters in authority segments', ({ expect }) => {
27
+ expect(id`foo123.bar456.qux`).toBe('foo123.bar456.qux');
28
+ });
29
+
30
+ test('interpolates values', ({ expect }) => {
31
+ const namespace = 'org.dxos';
32
+ const name = 'plugin';
33
+ expect(id`${namespace}.${name}.deck`).toBe('org.dxos.plugin.deck');
34
+ });
35
+
36
+ test('throws on empty string', ({ expect }) => {
37
+ expect(() => id``).toThrow(/Invalid id/);
38
+ });
39
+
40
+ test('throws for fewer than three parts', ({ expect }) => {
41
+ expect(() => id`foo`).toThrow(/Invalid id/);
42
+ expect(() => id`foo.bar`).toThrow(/Invalid id/);
43
+ });
44
+
45
+ test('throws when first character is a digit', ({ expect }) => {
46
+ expect(() => id`1foo.bar.baz`).toThrow(/Invalid id/);
47
+ });
48
+
49
+ test('throws on leading or trailing dot', ({ expect }) => {
50
+ expect(() => id`.foo.bar.baz`).toThrow(/Invalid id/);
51
+ expect(() => id`foo.bar.baz.`).toThrow(/Invalid id/);
52
+ });
53
+
54
+ test('throws on consecutive dots', ({ expect }) => {
55
+ expect(() => id`foo..bar`).toThrow(/Invalid id/);
56
+ });
57
+
58
+ test('throws on hyphens in the name segment', ({ expect }) => {
59
+ expect(() => id`org.example.foo-bar`).toThrow(/Invalid id/);
60
+ });
61
+
62
+ test('throws on disallowed characters', ({ expect }) => {
63
+ expect(() => id`foo_bar.baz.qux`).toThrow(/Invalid id/);
64
+ expect(() => id`foo bar.baz.qux`).toThrow(/Invalid id/);
65
+ });
66
+
67
+ test('throws when an interpolated value produces an invalid name segment', ({ expect }) => {
68
+ const bad = '1bad';
69
+ expect(() => id`org.dxos.${bad}`).toThrow(/Invalid id/);
70
+ });
71
+ });
72
+
73
+ describe('isWellFormedId', () => {
74
+ test('matches valid ids', ({ expect }) => {
75
+ expect(isWellFormedId('com.example.foo')).toBe(true);
76
+ expect(isWellFormedId('org.dxos.plugin.deck')).toBe(true);
77
+ expect(isWellFormedId('a1.b2.c3')).toBe(true);
78
+ expect(isWellFormedId('my-org.example.foo')).toBe(true);
79
+ });
80
+
81
+ test('rejects ids with fewer than three parts', ({ expect }) => {
82
+ expect(isWellFormedId('foo')).toBe(false);
83
+ expect(isWellFormedId('foo.bar')).toBe(false);
84
+ });
85
+
86
+ test('rejects invalid ids', ({ expect }) => {
87
+ expect(isWellFormedId('')).toBe(false);
88
+ expect(isWellFormedId('1foo.bar.baz')).toBe(false);
89
+ expect(isWellFormedId('foo.bar.')).toBe(false);
90
+ expect(isWellFormedId('.foo.bar')).toBe(false);
91
+ expect(isWellFormedId('foo..bar.baz')).toBe(false);
92
+ expect(isWellFormedId('org.example.foo-bar')).toBe(false);
93
+ });
94
+ });
95
+
96
+ describe('isWellFormedIdPart', () => {
97
+ test('matches valid name segments', ({ expect }) => {
98
+ expect(isWellFormedIdPart('foo')).toBe(true);
99
+ expect(isWellFormedIdPart('Foo')).toBe(true);
100
+ expect(isWellFormedIdPart('fooBar123')).toBe(true);
101
+ });
102
+
103
+ test('rejects invalid name segments', ({ expect }) => {
104
+ expect(isWellFormedIdPart('foo.bar')).toBe(false);
105
+ expect(isWellFormedIdPart('foo-bar')).toBe(false);
106
+ expect(isWellFormedIdPart('1foo')).toBe(false);
107
+ expect(isWellFormedIdPart('')).toBe(false);
108
+ });
109
+ });
package/src/id.ts ADDED
@@ -0,0 +1,48 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // Name segment: starts with alpha, alphanumeric only (no hyphens), max 63 chars.
6
+ const PART = /^[a-zA-Z][a-zA-Z0-9]{0,62}$/;
7
+
8
+ // Reference: https://atproto.com/specs/nsid
9
+ const ID =
10
+ /^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z0-9]{0,62})?)$/;
11
+
12
+ /**
13
+ * Branded string type for well-formed ids.
14
+ */
15
+ export type Id = string & { readonly __id: unique symbol };
16
+
17
+ /**
18
+ * Tagged template literal that constructs a well-formed, dot-delimited id string.
19
+ * Throws if the resulting string is not well-formed.
20
+ *
21
+ * Follows the AT Protocol NSID convention (https://atproto.com/specs/nsid)
22
+ * Full NSID: authority segments (hyphens allowed) + dot + name segment (no hyphens).
23
+ * - Authority segments: at most 253 characters (including periods), and must contain at least two segments.
24
+ * - Name segment: starts with alpha, alphanumeric only (no hyphens), max 63 chars.
25
+ *
26
+ * @example
27
+ * id`org.dxos.plugin.deck` // 'org.dxos.plugin.deck'
28
+ * id`${ns}.${plugin}.deck` // joins interpolated values
29
+ */
30
+ export function id(strings: TemplateStringsArray, ...values: unknown[]): Id {
31
+ const raw = strings.reduce((out, str, i) => out + str + (i < values.length ? String(values[i]) : ''), '');
32
+ if (!ID.test(raw)) {
33
+ throw new Error(`Invalid id (expected AT Protocol NSID): ${JSON.stringify(raw)}`);
34
+ }
35
+
36
+ return raw as Id;
37
+ }
38
+
39
+ /**
40
+ * Returns true if the given string is a well-formed NSID.
41
+ */
42
+ export const isWellFormedId = (value: string): boolean => ID.test(value);
43
+
44
+ /**
45
+ * Returns true if the given string is a well-formed NSID name segment:
46
+ * `/^[a-zA-Z][a-zA-Z0-9]{0,62}$/` (starts with alpha, alphanumeric only, no dots or hyphens).
47
+ */
48
+ export const isWellFormedIdPart = (value: string): boolean => PART.test(value);
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export * from './error-format';
24
24
  export * from './filename';
25
25
  export * from './for-each-async';
26
26
  export * from './human-hash';
27
+ export * from './id';
27
28
  export * from './instance-id';
28
29
  export * from './interval';
29
30
  export * from './join-tables';
package/src/map-values.ts CHANGED
@@ -52,6 +52,9 @@ class DeepMapper {
52
52
  res[i] = this._map(value[i], i);
53
53
  }
54
54
  return res;
55
+ } else if (ArrayBuffer.isView(value)) {
56
+ // Typed arrays (Uint8Array, etc.) are opaque leaf values, not POJOs to recurse into.
57
+ return value;
55
58
  } else if (value !== null && typeof value === 'object') {
56
59
  const res: any = {};
57
60
  this._cyclic.set(value, res);
@@ -112,6 +115,9 @@ class DeepMapperAsync {
112
115
  res[i] = await this._map(value[i], i);
113
116
  }
114
117
  return res;
118
+ } else if (ArrayBuffer.isView(value)) {
119
+ // Typed arrays (Uint8Array, etc.) are opaque leaf values, not POJOs to recurse into.
120
+ return value;
115
121
  } else if (value !== null && typeof value === 'object') {
116
122
  const res: any = {};
117
123
  this._cyclic.set(value, res);
@@ -2,7 +2,7 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { describe, expect, it } from 'vitest';
5
+ import { describe, test } from 'vitest';
6
6
 
7
7
  import { type Position, byPosition } from './position';
8
8
 
@@ -12,14 +12,14 @@ type TestItem = {
12
12
  };
13
13
 
14
14
  describe('byPosition', () => {
15
- it('should keep items with same position in their original order', () => {
15
+ test('should keep items with same position in their original order', ({ expect }) => {
16
16
  const items: TestItem[] = [
17
- { id: 1, position: 'static' },
18
- { id: 2, position: 'static' },
19
- { id: 3, position: 'hoist' },
20
- { id: 4, position: 'hoist' },
21
- { id: 5, position: 'fallback' },
22
- { id: 6, position: 'fallback' },
17
+ { id: 1 },
18
+ { id: 2 },
19
+ { id: 3, position: 'first' },
20
+ { id: 4, position: 'first' },
21
+ { id: 5, position: 'last' },
22
+ { id: 6, position: 'last' },
23
23
  ];
24
24
 
25
25
  const sorted = [...items].sort(byPosition);
@@ -30,69 +30,63 @@ describe('byPosition', () => {
30
30
  expect(sorted.findIndex((item) => item.id === 5)).toBeLessThan(sorted.findIndex((item) => item.id === 6));
31
31
  });
32
32
 
33
- it('should place "hoist" items before "static" items', () => {
34
- const items: TestItem[] = [
35
- { id: 1, position: 'static' },
36
- { id: 2, position: 'hoist' },
37
- ];
33
+ test('should place "first" items before items in natural order', ({ expect }) => {
34
+ const items: TestItem[] = [{ id: 1 }, { id: 2, position: 'first' }];
38
35
 
39
36
  const sorted = [...items].sort(byPosition);
40
- expect(sorted[0].position).toBe('hoist');
41
- expect(sorted[1].position).toBe('static');
37
+ expect(sorted[0].position).toBe('first');
38
+ expect(sorted[1].position).toBeUndefined();
42
39
  });
43
40
 
44
- it('should place "fallback" items after "static" items', () => {
45
- const items: TestItem[] = [
46
- { id: 1, position: 'fallback' },
47
- { id: 2, position: 'static' },
48
- ];
41
+ test('should place "last" items after items in natural order', ({ expect }) => {
42
+ const items: TestItem[] = [{ id: 1, position: 'last' }, { id: 2 }];
49
43
 
50
44
  const sorted = [...items].sort(byPosition);
51
- expect(sorted[0].position).toBe('static');
52
- expect(sorted[1].position).toBe('fallback');
45
+ expect(sorted[0].position).toBeUndefined();
46
+ expect(sorted[1].position).toBe('last');
53
47
  });
54
48
 
55
- it('should treat items without position as "static"', () => {
56
- const items: TestItem[] = [{ id: 1 }, { id: 2, position: 'hoist' }, { id: 3, position: 'fallback' }];
49
+ test('should treat items without position as natural order', ({ expect }) => {
50
+ const items: TestItem[] = [{ id: 1 }, { id: 2, position: 'first' }, { id: 3, position: 'last' }];
57
51
 
58
52
  const sorted = [...items].sort(byPosition);
59
- expect(sorted[0].position).toBe('hoist');
53
+ expect(sorted[0].position).toBe('first');
60
54
  expect(sorted[1].position).toBeUndefined();
61
- expect(sorted[2].position).toBe('fallback');
55
+ expect(sorted[2].position).toBe('last');
62
56
  });
63
57
 
64
- it('should correctly sort mixed positions', () => {
58
+ test('should correctly sort mixed positions', ({ expect }) => {
65
59
  const items: TestItem[] = [
66
- { id: 1, position: 'fallback' },
67
- { id: 2, position: 'static' },
68
- { id: 3, position: 'hoist' },
69
- { id: 4 }, // implicit static
70
- { id: 5, position: 'hoist' },
71
- { id: 6, position: 'fallback' },
60
+ { id: 1, position: 'last' },
61
+ { id: 2 },
62
+ { id: 3, position: 'first' },
63
+ { id: 4 },
64
+ { id: 5, position: 'first' },
65
+ { id: 6, position: 'last' },
72
66
  ];
73
67
 
74
68
  const sorted = [...items].sort(byPosition);
75
69
 
76
- // All hoisted items should come first
77
- expect(sorted[0].position).toBe('hoist');
78
- expect(sorted[1].position).toBe('hoist');
70
+ // All "first" items should come first
71
+ expect(sorted[0].position).toBe('first');
72
+ expect(sorted[1].position).toBe('first');
79
73
 
80
- // Static items (including undefined) should be in the middle
81
- expect(sorted[2].position).toBe('static');
74
+ // Natural-order items (undefined) should be in the middle
75
+ expect(sorted[2].position).toBeUndefined();
82
76
  expect(sorted[3].position).toBeUndefined();
83
77
 
84
- // Fallback items should be last
85
- expect(sorted[4].position).toBe('fallback');
86
- expect(sorted[5].position).toBe('fallback');
78
+ // "last" items should be at the end
79
+ expect(sorted[4].position).toBe('last');
80
+ expect(sorted[5].position).toBe('last');
87
81
  });
88
82
 
89
- it('should handle empty arrays', () => {
83
+ test('should handle empty arrays', ({ expect }) => {
90
84
  const items: TestItem[] = [];
91
85
  expect(() => [...items].sort(byPosition)).not.toThrow();
92
86
  });
93
87
 
94
- it('should handle single item arrays', () => {
95
- const items: TestItem[] = [{ id: 1, position: 'static' }];
88
+ test('should handle single item arrays', ({ expect }) => {
89
+ const items: TestItem[] = [{ id: 1 }];
96
90
  expect(() => [...items].sort(byPosition)).not.toThrow();
97
91
  });
98
92
  });
package/src/position.ts CHANGED
@@ -6,25 +6,21 @@
6
6
 
7
7
  /**
8
8
  * Determines priority order:
9
- * - `static` - Remain in natural order.
10
- * - `hoist` - Placed before `static`.
11
- * - `fallback` - Placed after `static`.
9
+ * - `undefined` - Remain in natural order.
10
+ * - `first` - Placed before items in natural order.
11
+ * - `last` - Placed after items in natural order.
12
12
  */
13
- // TODO(wittjosiah): Change to 'static' | 'start' | 'end'.
14
- export type Position = 'static' | 'hoist' | 'fallback';
13
+ export type Position = 'first' | 'last';
15
14
 
16
15
  /**
17
16
  * Sorting function for sorting by position.
18
17
  */
19
- export const byPosition = <T extends { position?: Position }>(
20
- { position: a = 'static' }: T,
21
- { position: b = 'static' }: T,
22
- ) => {
18
+ export const byPosition = <T extends { position?: Position }>({ position: a }: T, { position: b }: T) => {
23
19
  if (a === b) {
24
20
  return 0;
25
- } else if (a === 'hoist' || b === 'fallback') {
21
+ } else if (a === 'first' || b === 'last') {
26
22
  return -1;
27
- } else if (b === 'hoist' || a === 'fallback') {
23
+ } else if (b === 'first' || a === 'last') {
28
24
  return 1;
29
25
  } else {
30
26
  return 0;
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { describe, test } from 'vitest';
6
6
 
7
- import { trim } from './string';
7
+ import { inline, trim } from './string';
8
8
 
9
9
  describe('string', () => {
10
10
  test('dedent', async ({ expect }) => {
@@ -16,4 +16,27 @@ describe('string', () => {
16
16
 
17
17
  expect(text).to.eq('- 1\n- 2\n - 3');
18
18
  });
19
+
20
+ test('inline collapses whitespace to single spaces', async ({ expect }) => {
21
+ const classes = inline`
22
+ rounded-xs outline-none text-end
23
+ data-[type=year]:min-w-[4ch]
24
+ data-[focused]:bg-accent-bg data-[focused]:text-accent-fg
25
+ `;
26
+
27
+ expect(classes).to.eq(
28
+ 'rounded-xs outline-none text-end data-[type=year]:min-w-[4ch] data-[focused]:bg-accent-bg data-[focused]:text-accent-fg',
29
+ );
30
+ });
31
+
32
+ test('inline interpolates values', async ({ expect }) => {
33
+ const variant = 'data-[focused]:bg-accent-bg';
34
+ const classes = inline`
35
+ rounded-xs
36
+ ${variant}
37
+ outline-none
38
+ `;
39
+
40
+ expect(classes).to.eq('rounded-xs data-[focused]:bg-accent-bg outline-none');
41
+ });
19
42
  });
package/src/string.ts CHANGED
@@ -13,6 +13,22 @@ export const capitalize = (str: string): string => {
13
13
  return str.charAt(0).toUpperCase() + str.slice(1);
14
14
  };
15
15
 
16
+ /**
17
+ * Collapse a multi-line tagged template literal to a single space-separated string.
18
+ * Useful for statically concatenating long lists of class names across multiple lines:
19
+ *
20
+ * ```ts
21
+ * const cls = inline`
22
+ * rounded-xs outline-none
23
+ * data-[focused]:bg-accent-bg
24
+ * `;
25
+ * ```
26
+ */
27
+ export function inline(strings: TemplateStringsArray, ...values: any[]): string {
28
+ const raw = strings.reduce((out, str, i) => out + str + (i < values.length ? String(values[i]) : ''), '');
29
+ return raw.replace(/\s+/g, ' ').trim();
30
+ }
31
+
16
32
  /**
17
33
  * Remove leading space from multi-line strings.
18
34
  */
package/src/uint8array.ts CHANGED
@@ -35,3 +35,47 @@ export const bufferToArray = (buffer: Buffer): Uint8Array => {
35
35
  export const stringToArray = (string: string): Uint8Array => bufferToArray(Buffer.from(string, 'hex'));
36
36
 
37
37
  export const arrayToString = (array: Uint8Array): string => arrayToBuffer(array).toString('hex');
38
+
39
+ /**
40
+ * JSON-safe representation of a `Uint8Array`, per the IPLD DAG-JSON spec.
41
+ * https://ipld.io/specs/codecs/dag-json/spec/#bytes
42
+ *
43
+ * Bytes are encoded as `{ "/": { "bytes": "<base64-no-padding>" } }`.
44
+ * Distinguishable from encoded references `{ "/": "<string>" }` by the type of the `/` value.
45
+ */
46
+ export type EncodedUint8Array = { '/': { bytes: string } };
47
+
48
+ const toUnpaddedBase64 = (bytes: Uint8Array): string => arrayToBuffer(bytes).toString('base64').replace(/=+$/, '');
49
+
50
+ /**
51
+ * Encode a `Uint8Array` as the DAG-JSON bytes form `{ '/': { bytes: '<base64-no-padding>' } }`.
52
+ */
53
+ export const encodeUint8ArrayToJson = (bytes: Uint8Array): EncodedUint8Array => ({
54
+ '/': { bytes: toUnpaddedBase64(bytes) },
55
+ });
56
+
57
+ /**
58
+ * Type-guard that returns true iff `value` is the DAG-JSON bytes form produced by
59
+ * {@link encodeUint8ArrayToJson} — an object with exactly one key `'/'` whose value is an
60
+ * object with exactly one string key `bytes`.
61
+ */
62
+ export const isEncodedUint8Array = (value: unknown): value is EncodedUint8Array => {
63
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
64
+ return false;
65
+ }
66
+ if (Object.keys(value).length !== 1) {
67
+ return false;
68
+ }
69
+ const inner = (value as any)['/'];
70
+ if (typeof inner !== 'object' || inner === null || Array.isArray(inner)) {
71
+ return false;
72
+ }
73
+ return Object.keys(inner).length === 1 && typeof inner.bytes === 'string';
74
+ };
75
+
76
+ /**
77
+ * Decode a DAG-JSON bytes form (as produced by {@link encodeUint8ArrayToJson}) back to a
78
+ * `Uint8Array`. The base64 string is tolerated with or without padding.
79
+ */
80
+ export const decodeUint8ArrayFromJson = (encoded: EncodedUint8Array): Uint8Array =>
81
+ bufferToArray(Buffer.from(encoded['/'].bytes, 'base64'));
package/src/unit.ts CHANGED
@@ -53,6 +53,8 @@ export const Unit: Record<string, UnitFormat> = {
53
53
  Minute: createFormat({ symbol: 'm', quotient: MS_MINUTES }),
54
54
  Second: createFormat({ symbol: 's', quotient: MS_SECONDS, precision: 1 }),
55
55
  Millisecond: createFormat({ symbol: 'ms', quotient: 1 }),
56
+
57
+ // TODO(burdon): Inconsistent formattedValue.
56
58
  Duration: (n: number) => {
57
59
  const hours = Math.floor(n / MS_HOURS);
58
60
  const minutes = Math.floor((n % MS_HOURS) / MS_MINUTES);
@@ -68,7 +70,7 @@ export const Unit: Record<string, UnitFormat> = {
68
70
 
69
71
  if (minutes) {
70
72
  const seconds = (n - MS_MINUTES * minutes) / MS_SECONDS;
71
- const formattedValue = seconds ? `${minutes}m ${seconds}s` : `${minutes}m`;
73
+ const formattedValue = seconds ? `${minutes}m ${Math.round(seconds)}s` : `${minutes}m`;
72
74
  return {
73
75
  unit: { symbol: 'm', quotient: MS_MINUTES },
74
76
  value: minutes,