@nixxie-cms/fields-duration 1.0.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/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nixxie International DMCC
4
+ Portions Copyright (c) 2023 Thinkmill Labs Pty Ltd and contributors
5
+ (this software is derived from the KeystoneJS project, https://keystonejs.com)
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # @nixxie-cms/fields-duration
2
+
3
+ A duration field for Nixxie CMS. Stores a whole number of **seconds** on the core `integer`
4
+ field, with optional `min`/`max` bounds enforced via validation hooks.
5
+
6
+ ```ts
7
+ import { duration } from '@nixxie-cms/fields-duration'
8
+
9
+ fields: {
10
+ runtime: duration({
11
+ min: 60, // at least one minute
12
+ max: 4 * 3600, // at most four hours
13
+ validation: { isRequired: true },
14
+ }),
15
+ }
16
+ ```
17
+
18
+ Also exports dependency-free helpers:
19
+
20
+ - `formatDuration(seconds)` → `"1h 30m 5s"` (omits zero units, `"0s"` for 0)
21
+ - `parseDuration(input)` → seconds or `null`; accepts `"90"`, `"90s"`, `"1h 30m"`,
22
+ `"1:30:05"` and ISO-8601 (`"PT1H30M5S"`)
@@ -0,0 +1,35 @@
1
+ import type { IntegerFieldConfig } from '@nixxie-cms/core/fields';
2
+ import type { BaseCollectionTypeInfo, FieldTypeFunc } from '@nixxie-cms/core/types';
3
+ /**
4
+ * Format a duration in seconds as `"1h 30m 5s"`. Zero units are omitted (`3600` → `"1h"`),
5
+ * zero itself is `"0s"`, and negative durations get a leading `-`. Hours are not rolled up
6
+ * into days (`90000` → `"25h"`). Fractional input is rounded; non-finite input yields `''`.
7
+ */
8
+ export declare function formatDuration(seconds: number): string;
9
+ /**
10
+ * Parse a human-entered duration into whole seconds. Dependency-free; accepts:
11
+ * - plain seconds: `"90"`
12
+ * - unit tokens: `"90s"`, `"1h 30m"`, `"2d 4h"` (units `d`, `h`, `m`, `s`, case-insensitive)
13
+ * - colon notation: `"1:30:05"` (h:mm:ss) or `"1:30"` (m:ss)
14
+ * - ISO-8601 durations: `"PT1H30M5S"`, `"P1DT12H"` (day/time components only)
15
+ *
16
+ * Fractional values are rounded to the nearest second. Returns `null` when unparseable.
17
+ */
18
+ export declare function parseDuration(input: string): number | null;
19
+ export type DurationFieldConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> = Omit<IntegerFieldConfig<CollectionTypeInfo>, 'validation'> & {
20
+ /** Smallest allowed duration, in seconds. */
21
+ min?: number;
22
+ /** Largest allowed duration, in seconds. */
23
+ max?: number;
24
+ validation?: {
25
+ /** Reject saving when no value is present. */
26
+ isRequired?: boolean;
27
+ };
28
+ };
29
+ /**
30
+ * A duration field storing a whole number of SECONDS. Built on the core `integer` field with
31
+ * optional `min`/`max` bounds enforced via a validate hook. Use `formatDuration` /
32
+ * `parseDuration` to convert to and from human-readable forms.
33
+ */
34
+ export declare function duration<CollectionTypeInfo extends BaseCollectionTypeInfo>(config?: DurationFieldConfig<CollectionTypeInfo>): FieldTypeFunc<CollectionTypeInfo>;
35
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"../../../src","sources":["index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AACjE,OAAO,KAAK,EAAE,sBAAsB,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAMnF;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAatD;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiD1D;AAED,MAAM,MAAM,mBAAmB,CAAC,kBAAkB,SAAS,sBAAsB,IAAI,IAAI,CACvF,kBAAkB,CAAC,kBAAkB,CAAC,EACtC,YAAY,CACb,GAAG;IACF,6CAA6C;IAC7C,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,4CAA4C;IAC5C,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,UAAU,CAAC,EAAE;QACX,8CAA8C;QAC9C,UAAU,CAAC,EAAE,OAAO,CAAA;KACrB,CAAA;CACF,CAAA;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,kBAAkB,SAAS,sBAAsB,EACxE,MAAM,GAAE,mBAAmB,CAAC,kBAAkB,CAAM,GACnD,aAAa,CAAC,kBAAkB,CAAC,CA+BnC"}
@@ -0,0 +1,2 @@
1
+ export * from "./declarations/src/index.js";
2
+ //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibml4eGllLWNtcy1maWVsZHMtZHVyYXRpb24uY2pzLmQudHMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuL2RlY2xhcmF0aW9ucy9zcmMvaW5kZXguZC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSJ9
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var fields = require('@nixxie-cms/core/fields');
6
+
7
+ const SECONDS_PER_MINUTE = 60;
8
+ const SECONDS_PER_HOUR = 3600;
9
+ const SECONDS_PER_DAY = 86400;
10
+
11
+ /**
12
+ * Format a duration in seconds as `"1h 30m 5s"`. Zero units are omitted (`3600` → `"1h"`),
13
+ * zero itself is `"0s"`, and negative durations get a leading `-`. Hours are not rolled up
14
+ * into days (`90000` → `"25h"`). Fractional input is rounded; non-finite input yields `''`.
15
+ */
16
+ function formatDuration(seconds) {
17
+ if (!Number.isFinite(seconds)) return '';
18
+ const sign = seconds < 0 ? '-' : '';
19
+ const total = Math.round(Math.abs(seconds));
20
+ const h = Math.floor(total / SECONDS_PER_HOUR);
21
+ const m = Math.floor(total % SECONDS_PER_HOUR / SECONDS_PER_MINUTE);
22
+ const s = total % SECONDS_PER_MINUTE;
23
+ const parts = [];
24
+ if (h > 0) parts.push(`${h}h`);
25
+ if (m > 0) parts.push(`${m}m`);
26
+ if (s > 0) parts.push(`${s}s`);
27
+ if (parts.length === 0) return '0s';
28
+ return sign + parts.join(' ');
29
+ }
30
+
31
+ /**
32
+ * Parse a human-entered duration into whole seconds. Dependency-free; accepts:
33
+ * - plain seconds: `"90"`
34
+ * - unit tokens: `"90s"`, `"1h 30m"`, `"2d 4h"` (units `d`, `h`, `m`, `s`, case-insensitive)
35
+ * - colon notation: `"1:30:05"` (h:mm:ss) or `"1:30"` (m:ss)
36
+ * - ISO-8601 durations: `"PT1H30M5S"`, `"P1DT12H"` (day/time components only)
37
+ *
38
+ * Fractional values are rounded to the nearest second. Returns `null` when unparseable.
39
+ */
40
+ function parseDuration(input) {
41
+ const s = input.trim();
42
+ if (s === '') return null;
43
+
44
+ // Plain number of seconds, e.g. "90" or "90.5".
45
+ if (/^\d+(?:\.\d+)?$/.test(s)) return Math.round(parseFloat(s));
46
+
47
+ // ISO-8601 duration, limited to day/time components (P1DT2H3M4S). Years/months/weeks have
48
+ // no fixed length in seconds, so they are deliberately not supported.
49
+ const iso = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i.exec(s);
50
+ if (iso) {
51
+ const [, d, h, m, sec] = iso;
52
+ // Bare "P"/"PT" matches the regex but carries no components — reject it.
53
+ if (d === undefined && h === undefined && m === undefined && sec === undefined) return null;
54
+ return Math.round(parseFloat(d !== null && d !== void 0 ? d : '0') * SECONDS_PER_DAY + parseFloat(h !== null && h !== void 0 ? h : '0') * SECONDS_PER_HOUR + parseFloat(m !== null && m !== void 0 ? m : '0') * SECONDS_PER_MINUTE + parseFloat(sec !== null && sec !== void 0 ? sec : '0'));
55
+ }
56
+
57
+ // Colon notation: "1:30:05" (h:mm:ss) or "1:30" (m:ss).
58
+ const colon = /^(\d+):([0-5]?\d)(?::([0-5]?\d))?$/.exec(s);
59
+ if (colon) {
60
+ const [, a, b, c] = colon;
61
+ return c !== undefined ? parseInt(a, 10) * SECONDS_PER_HOUR + parseInt(b, 10) * SECONDS_PER_MINUTE + parseInt(c, 10) : parseInt(a, 10) * SECONDS_PER_MINUTE + parseInt(b, 10);
62
+ }
63
+
64
+ // Unit tokens: "1h 30m 5s", "90s", "2d4h" — one or more value/unit pairs.
65
+ if (/^(?:\s*\d+(?:\.\d+)?\s*[dhms]\s*)+$/i.test(s)) {
66
+ const multipliers = {
67
+ d: SECONDS_PER_DAY,
68
+ h: SECONDS_PER_HOUR,
69
+ m: SECONDS_PER_MINUTE,
70
+ s: 1
71
+ };
72
+ let total = 0;
73
+ const token = /(\d+(?:\.\d+)?)\s*([dhms])/gi;
74
+ let match;
75
+ while (match = token.exec(s)) {
76
+ total += parseFloat(match[1]) * multipliers[match[2].toLowerCase()];
77
+ }
78
+ return Math.round(total);
79
+ }
80
+ return null;
81
+ }
82
+ /**
83
+ * A duration field storing a whole number of SECONDS. Built on the core `integer` field with
84
+ * optional `min`/`max` bounds enforced via a validate hook. Use `formatDuration` /
85
+ * `parseDuration` to convert to and from human-readable forms.
86
+ */
87
+ function duration(config = {}) {
88
+ const {
89
+ min,
90
+ max,
91
+ hooks,
92
+ validation,
93
+ ...rest
94
+ } = config;
95
+ const userValidate = hooks === null || hooks === void 0 ? void 0 : hooks.validate;
96
+ return fields.integer({
97
+ ...rest,
98
+ validation: {
99
+ isRequired: validation === null || validation === void 0 ? void 0 : validation.isRequired
100
+ },
101
+ ui: {
102
+ description: 'Duration in seconds',
103
+ ...rest.ui
104
+ },
105
+ hooks: {
106
+ ...hooks,
107
+ validate: async args => {
108
+ var _userValidate;
109
+ // Preserve any user-supplied validate hook (function or { create, update, delete } form).
110
+ if (typeof userValidate === 'function') await userValidate(args);else await (userValidate === null || userValidate === void 0 || (_userValidate = userValidate[args.operation]) === null || _userValidate === void 0 ? void 0 : _userValidate.call(userValidate, args));
111
+ const {
112
+ resolvedData,
113
+ fieldKey,
114
+ addValidationError,
115
+ operation
116
+ } = args;
117
+ if (operation === 'delete') return;
118
+ const value = resolvedData[fieldKey];
119
+ if (typeof value !== 'number') return;
120
+ if (min !== undefined && value < min) {
121
+ addValidationError(`${fieldKey} must be at least ${min} seconds (${formatDuration(min)})`);
122
+ }
123
+ if (max !== undefined && value > max) {
124
+ addValidationError(`${fieldKey} must be at most ${max} seconds (${formatDuration(max)})`);
125
+ }
126
+ }
127
+ }
128
+ });
129
+ }
130
+
131
+ exports.duration = duration;
132
+ exports.formatDuration = formatDuration;
133
+ exports.parseDuration = parseDuration;
@@ -0,0 +1,127 @@
1
+ import { integer } from '@nixxie-cms/core/fields';
2
+
3
+ const SECONDS_PER_MINUTE = 60;
4
+ const SECONDS_PER_HOUR = 3600;
5
+ const SECONDS_PER_DAY = 86400;
6
+
7
+ /**
8
+ * Format a duration in seconds as `"1h 30m 5s"`. Zero units are omitted (`3600` → `"1h"`),
9
+ * zero itself is `"0s"`, and negative durations get a leading `-`. Hours are not rolled up
10
+ * into days (`90000` → `"25h"`). Fractional input is rounded; non-finite input yields `''`.
11
+ */
12
+ function formatDuration(seconds) {
13
+ if (!Number.isFinite(seconds)) return '';
14
+ const sign = seconds < 0 ? '-' : '';
15
+ const total = Math.round(Math.abs(seconds));
16
+ const h = Math.floor(total / SECONDS_PER_HOUR);
17
+ const m = Math.floor(total % SECONDS_PER_HOUR / SECONDS_PER_MINUTE);
18
+ const s = total % SECONDS_PER_MINUTE;
19
+ const parts = [];
20
+ if (h > 0) parts.push(`${h}h`);
21
+ if (m > 0) parts.push(`${m}m`);
22
+ if (s > 0) parts.push(`${s}s`);
23
+ if (parts.length === 0) return '0s';
24
+ return sign + parts.join(' ');
25
+ }
26
+
27
+ /**
28
+ * Parse a human-entered duration into whole seconds. Dependency-free; accepts:
29
+ * - plain seconds: `"90"`
30
+ * - unit tokens: `"90s"`, `"1h 30m"`, `"2d 4h"` (units `d`, `h`, `m`, `s`, case-insensitive)
31
+ * - colon notation: `"1:30:05"` (h:mm:ss) or `"1:30"` (m:ss)
32
+ * - ISO-8601 durations: `"PT1H30M5S"`, `"P1DT12H"` (day/time components only)
33
+ *
34
+ * Fractional values are rounded to the nearest second. Returns `null` when unparseable.
35
+ */
36
+ function parseDuration(input) {
37
+ const s = input.trim();
38
+ if (s === '') return null;
39
+
40
+ // Plain number of seconds, e.g. "90" or "90.5".
41
+ if (/^\d+(?:\.\d+)?$/.test(s)) return Math.round(parseFloat(s));
42
+
43
+ // ISO-8601 duration, limited to day/time components (P1DT2H3M4S). Years/months/weeks have
44
+ // no fixed length in seconds, so they are deliberately not supported.
45
+ const iso = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i.exec(s);
46
+ if (iso) {
47
+ const [, d, h, m, sec] = iso;
48
+ // Bare "P"/"PT" matches the regex but carries no components — reject it.
49
+ if (d === undefined && h === undefined && m === undefined && sec === undefined) return null;
50
+ return Math.round(parseFloat(d !== null && d !== void 0 ? d : '0') * SECONDS_PER_DAY + parseFloat(h !== null && h !== void 0 ? h : '0') * SECONDS_PER_HOUR + parseFloat(m !== null && m !== void 0 ? m : '0') * SECONDS_PER_MINUTE + parseFloat(sec !== null && sec !== void 0 ? sec : '0'));
51
+ }
52
+
53
+ // Colon notation: "1:30:05" (h:mm:ss) or "1:30" (m:ss).
54
+ const colon = /^(\d+):([0-5]?\d)(?::([0-5]?\d))?$/.exec(s);
55
+ if (colon) {
56
+ const [, a, b, c] = colon;
57
+ return c !== undefined ? parseInt(a, 10) * SECONDS_PER_HOUR + parseInt(b, 10) * SECONDS_PER_MINUTE + parseInt(c, 10) : parseInt(a, 10) * SECONDS_PER_MINUTE + parseInt(b, 10);
58
+ }
59
+
60
+ // Unit tokens: "1h 30m 5s", "90s", "2d4h" — one or more value/unit pairs.
61
+ if (/^(?:\s*\d+(?:\.\d+)?\s*[dhms]\s*)+$/i.test(s)) {
62
+ const multipliers = {
63
+ d: SECONDS_PER_DAY,
64
+ h: SECONDS_PER_HOUR,
65
+ m: SECONDS_PER_MINUTE,
66
+ s: 1
67
+ };
68
+ let total = 0;
69
+ const token = /(\d+(?:\.\d+)?)\s*([dhms])/gi;
70
+ let match;
71
+ while (match = token.exec(s)) {
72
+ total += parseFloat(match[1]) * multipliers[match[2].toLowerCase()];
73
+ }
74
+ return Math.round(total);
75
+ }
76
+ return null;
77
+ }
78
+ /**
79
+ * A duration field storing a whole number of SECONDS. Built on the core `integer` field with
80
+ * optional `min`/`max` bounds enforced via a validate hook. Use `formatDuration` /
81
+ * `parseDuration` to convert to and from human-readable forms.
82
+ */
83
+ function duration(config = {}) {
84
+ const {
85
+ min,
86
+ max,
87
+ hooks,
88
+ validation,
89
+ ...rest
90
+ } = config;
91
+ const userValidate = hooks === null || hooks === void 0 ? void 0 : hooks.validate;
92
+ return integer({
93
+ ...rest,
94
+ validation: {
95
+ isRequired: validation === null || validation === void 0 ? void 0 : validation.isRequired
96
+ },
97
+ ui: {
98
+ description: 'Duration in seconds',
99
+ ...rest.ui
100
+ },
101
+ hooks: {
102
+ ...hooks,
103
+ validate: async args => {
104
+ var _userValidate;
105
+ // Preserve any user-supplied validate hook (function or { create, update, delete } form).
106
+ if (typeof userValidate === 'function') await userValidate(args);else await (userValidate === null || userValidate === void 0 || (_userValidate = userValidate[args.operation]) === null || _userValidate === void 0 ? void 0 : _userValidate.call(userValidate, args));
107
+ const {
108
+ resolvedData,
109
+ fieldKey,
110
+ addValidationError,
111
+ operation
112
+ } = args;
113
+ if (operation === 'delete') return;
114
+ const value = resolvedData[fieldKey];
115
+ if (typeof value !== 'number') return;
116
+ if (min !== undefined && value < min) {
117
+ addValidationError(`${fieldKey} must be at least ${min} seconds (${formatDuration(min)})`);
118
+ }
119
+ if (max !== undefined && value > max) {
120
+ addValidationError(`${fieldKey} must be at most ${max} seconds (${formatDuration(max)})`);
121
+ }
122
+ }
123
+ }
124
+ });
125
+ }
126
+
127
+ export { duration, formatDuration, parseDuration };
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@nixxie-cms/fields-duration",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "main": "dist/nixxie-cms-fields-duration.cjs.js",
6
+ "module": "dist/nixxie-cms-fields-duration.esm.js",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/nixxie-cms-fields-duration.cjs.js",
10
+ "module": "./dist/nixxie-cms-fields-duration.esm.js",
11
+ "default": "./dist/nixxie-cms-fields-duration.cjs.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "dependencies": {
16
+ "@babel/runtime": "^7.24.7"
17
+ },
18
+ "devDependencies": {
19
+ "@nixxie-cms/core": "^1.1.0"
20
+ },
21
+ "peerDependencies": {
22
+ "@nixxie-cms/core": "^1.0.1"
23
+ },
24
+ "preconstruct": {
25
+ "entrypoints": [
26
+ "index.ts"
27
+ ]
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/nixxiecms/nixxie/tree/main/packages/fields-duration"
32
+ }
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,141 @@
1
+ import { integer } from '@nixxie-cms/core/fields'
2
+ import type { IntegerFieldConfig } from '@nixxie-cms/core/fields'
3
+ import type { BaseCollectionTypeInfo, FieldTypeFunc } from '@nixxie-cms/core/types'
4
+
5
+ const SECONDS_PER_MINUTE = 60
6
+ const SECONDS_PER_HOUR = 3600
7
+ const SECONDS_PER_DAY = 86400
8
+
9
+ /**
10
+ * Format a duration in seconds as `"1h 30m 5s"`. Zero units are omitted (`3600` → `"1h"`),
11
+ * zero itself is `"0s"`, and negative durations get a leading `-`. Hours are not rolled up
12
+ * into days (`90000` → `"25h"`). Fractional input is rounded; non-finite input yields `''`.
13
+ */
14
+ export function formatDuration(seconds: number): string {
15
+ if (!Number.isFinite(seconds)) return ''
16
+ const sign = seconds < 0 ? '-' : ''
17
+ const total = Math.round(Math.abs(seconds))
18
+ const h = Math.floor(total / SECONDS_PER_HOUR)
19
+ const m = Math.floor((total % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE)
20
+ const s = total % SECONDS_PER_MINUTE
21
+ const parts: string[] = []
22
+ if (h > 0) parts.push(`${h}h`)
23
+ if (m > 0) parts.push(`${m}m`)
24
+ if (s > 0) parts.push(`${s}s`)
25
+ if (parts.length === 0) return '0s'
26
+ return sign + parts.join(' ')
27
+ }
28
+
29
+ /**
30
+ * Parse a human-entered duration into whole seconds. Dependency-free; accepts:
31
+ * - plain seconds: `"90"`
32
+ * - unit tokens: `"90s"`, `"1h 30m"`, `"2d 4h"` (units `d`, `h`, `m`, `s`, case-insensitive)
33
+ * - colon notation: `"1:30:05"` (h:mm:ss) or `"1:30"` (m:ss)
34
+ * - ISO-8601 durations: `"PT1H30M5S"`, `"P1DT12H"` (day/time components only)
35
+ *
36
+ * Fractional values are rounded to the nearest second. Returns `null` when unparseable.
37
+ */
38
+ export function parseDuration(input: string): number | null {
39
+ const s = input.trim()
40
+ if (s === '') return null
41
+
42
+ // Plain number of seconds, e.g. "90" or "90.5".
43
+ if (/^\d+(?:\.\d+)?$/.test(s)) return Math.round(parseFloat(s))
44
+
45
+ // ISO-8601 duration, limited to day/time components (P1DT2H3M4S). Years/months/weeks have
46
+ // no fixed length in seconds, so they are deliberately not supported.
47
+ const iso = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i.exec(s)
48
+ if (iso) {
49
+ const [, d, h, m, sec] = iso
50
+ // Bare "P"/"PT" matches the regex but carries no components — reject it.
51
+ if (d === undefined && h === undefined && m === undefined && sec === undefined) return null
52
+ return Math.round(
53
+ parseFloat(d ?? '0') * SECONDS_PER_DAY +
54
+ parseFloat(h ?? '0') * SECONDS_PER_HOUR +
55
+ parseFloat(m ?? '0') * SECONDS_PER_MINUTE +
56
+ parseFloat(sec ?? '0')
57
+ )
58
+ }
59
+
60
+ // Colon notation: "1:30:05" (h:mm:ss) or "1:30" (m:ss).
61
+ const colon = /^(\d+):([0-5]?\d)(?::([0-5]?\d))?$/.exec(s)
62
+ if (colon) {
63
+ const [, a, b, c] = colon
64
+ return c !== undefined
65
+ ? parseInt(a, 10) * SECONDS_PER_HOUR + parseInt(b, 10) * SECONDS_PER_MINUTE + parseInt(c, 10)
66
+ : parseInt(a, 10) * SECONDS_PER_MINUTE + parseInt(b, 10)
67
+ }
68
+
69
+ // Unit tokens: "1h 30m 5s", "90s", "2d4h" — one or more value/unit pairs.
70
+ if (/^(?:\s*\d+(?:\.\d+)?\s*[dhms]\s*)+$/i.test(s)) {
71
+ const multipliers: Record<string, number> = {
72
+ d: SECONDS_PER_DAY,
73
+ h: SECONDS_PER_HOUR,
74
+ m: SECONDS_PER_MINUTE,
75
+ s: 1,
76
+ }
77
+ let total = 0
78
+ const token = /(\d+(?:\.\d+)?)\s*([dhms])/gi
79
+ let match: RegExpExecArray | null
80
+ while ((match = token.exec(s))) {
81
+ total += parseFloat(match[1]) * multipliers[match[2].toLowerCase()]
82
+ }
83
+ return Math.round(total)
84
+ }
85
+
86
+ return null
87
+ }
88
+
89
+ export type DurationFieldConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> = Omit<
90
+ IntegerFieldConfig<CollectionTypeInfo>,
91
+ 'validation'
92
+ > & {
93
+ /** Smallest allowed duration, in seconds. */
94
+ min?: number
95
+ /** Largest allowed duration, in seconds. */
96
+ max?: number
97
+ validation?: {
98
+ /** Reject saving when no value is present. */
99
+ isRequired?: boolean
100
+ }
101
+ }
102
+
103
+ /**
104
+ * A duration field storing a whole number of SECONDS. Built on the core `integer` field with
105
+ * optional `min`/`max` bounds enforced via a validate hook. Use `formatDuration` /
106
+ * `parseDuration` to convert to and from human-readable forms.
107
+ */
108
+ export function duration<CollectionTypeInfo extends BaseCollectionTypeInfo>(
109
+ config: DurationFieldConfig<CollectionTypeInfo> = {}
110
+ ): FieldTypeFunc<CollectionTypeInfo> {
111
+ const { min, max, hooks, validation, ...rest } = config
112
+ const userValidate = hooks?.validate as
113
+ | ((args: any) => unknown)
114
+ | { create?: (args: any) => unknown; update?: (args: any) => unknown; delete?: (args: any) => unknown }
115
+ | undefined
116
+
117
+ return integer<CollectionTypeInfo>({
118
+ ...(rest as IntegerFieldConfig<CollectionTypeInfo>),
119
+ validation: { isRequired: validation?.isRequired },
120
+ ui: { description: 'Duration in seconds', ...rest.ui },
121
+ hooks: {
122
+ ...hooks,
123
+ validate: async (args: any) => {
124
+ // Preserve any user-supplied validate hook (function or { create, update, delete } form).
125
+ if (typeof userValidate === 'function') await userValidate(args)
126
+ else await userValidate?.[args.operation as 'create' | 'update' | 'delete']?.(args)
127
+
128
+ const { resolvedData, fieldKey, addValidationError, operation } = args
129
+ if (operation === 'delete') return
130
+ const value = resolvedData[fieldKey]
131
+ if (typeof value !== 'number') return
132
+ if (min !== undefined && value < min) {
133
+ addValidationError(`${fieldKey} must be at least ${min} seconds (${formatDuration(min)})`)
134
+ }
135
+ if (max !== undefined && value > max) {
136
+ addValidationError(`${fieldKey} must be at most ${max} seconds (${formatDuration(max)})`)
137
+ }
138
+ },
139
+ },
140
+ })
141
+ }