gymmonk-schema 0.50.0 → 0.52.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.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * gymmonk-schema — Body metrics: the derived numbers and the field catalogue
3
+ * ==========================================================================
4
+ * BMI and body-fat % are DERIVED, never persisted — `member-profile` stores
5
+ * only the raw cm / kg. That makes the formulas and their thresholds a contract
6
+ * rather than a rendering detail: two surfaces computing a member's BMI
7
+ * slightly differently would show that member two different numbers for the
8
+ * same body, and neither would be wrong on its own terms.
9
+ *
10
+ * This module therefore owns, for BOTH the client and the API:
11
+ * - the two formulas (BMI, U.S. Navy body fat)
12
+ * - the bands each result falls into, and the gauge bounds they are drawn on
13
+ * - the catalogue of measurements a member can record, and which raw field
14
+ * each one writes to
15
+ *
16
+ * It lived in the web client (`features/onboarding/lib/body-metrics`, later
17
+ * `features/profile/lib`), which meant the backend had no way to reach it and
18
+ * a second implementation would have been the only option the day anything
19
+ * server-side needed a BMI.
20
+ *
21
+ * What is deliberately NOT here: formatting. `"24 kg/m²"`, `"5′ 8″"` and
22
+ * `"157 lb"` are produced at the edge by the client, per this codebase's
23
+ * raw-values-format-at-edge rule. The unit KIND is here because it is part of
24
+ * what a measurement IS; turning it into a string is presentation.
25
+ *
26
+ * @module gymmonk-schema/body-metrics
27
+ */
28
+ import type { BodyMetrics } from './member-profile.js';
29
+ import type { Gender } from './shared.js';
30
+ /**
31
+ * Where a derived number sits against its healthy range.
32
+ *
33
+ * `tone` is a semantic band, not a colour: the client maps it to teal / amber /
34
+ * red. Naming the colour here would put a design decision in the contract.
35
+ */
36
+ export interface Tier {
37
+ label: string;
38
+ /** good = healthy, low/warn = caution, high = out of range. */
39
+ tone: 'low' | 'good' | 'warn' | 'high';
40
+ }
41
+ /** BMI = kg / m². Rounded to 1 decimal. Null until both inputs exist. */
42
+ export declare function calcBmi(heightCm: number | null, weightKg: number | null): number | null;
43
+ /** WHO BMI band the value falls in. */
44
+ export declare function bmiTier(bmi: number): Tier;
45
+ /** WHO healthy BMI range, and sensible visual bounds for the gauge. */
46
+ export declare const BMI_HEALTHY: [number, number];
47
+ export declare const BMI_GAUGE: [number, number];
48
+ /**
49
+ * U.S. Navy body-fat % from circumferences (cm). Men use neck + waist; women
50
+ * also use hips. Returns null until the needed inputs are present and valid.
51
+ *
52
+ * HEIGHT is an input to both variants, which is easy to miss: a member who
53
+ * filled in neck and waist but no height gets nothing back, and the screen has
54
+ * to say so rather than render an empty card.
55
+ */
56
+ export declare function calcBodyFatNavy(gender: Gender, heightCm: number | null, neckCm: number | null, waistCm: number | null, hipsCm: number | null): number | null;
57
+ /** ACE body-fat categories (gender-specific). */
58
+ export declare function bodyFatTier(gender: Gender, bf: number): Tier;
59
+ /** Healthy body-fat range (athletic → acceptable), and gauge bounds. */
60
+ export declare function bodyFatHealthy(gender: Gender): [number, number];
61
+ export declare const BODY_FAT_GAUGE: [number, number];
62
+ /**
63
+ * Which imperial reference a measurement is shown against.
64
+ *
65
+ * `height` is the only one people express in feet — a neck or waist in feet
66
+ * would be absurd, so every other length falls back to plain inches. The
67
+ * client turns this into `5′ 8″` or `34 in`; the kind itself is part of the
68
+ * measurement's definition and belongs with it.
69
+ */
70
+ export type ImperialKind = 'height' | 'length' | 'weight';
71
+ /** A measurement a member can record: its label, its unit, and where it lands. */
72
+ export interface MeasureField {
73
+ /** Stable form key. NOTE `hip` maps to `hipsCm` — the two names differ. */
74
+ key: string;
75
+ label: string;
76
+ unit: string;
77
+ metric: keyof BodyMetrics;
78
+ imperial: ImperialKind;
79
+ }
80
+ /**
81
+ * Every measurement the record can hold, in the order a body reads top to
82
+ * bottom.
83
+ *
84
+ * TEN, while the member-facing editor asks for five. Height and weight give
85
+ * BMI; neck, waist and hips give the body-fat estimate; the remaining five are
86
+ * progress-tracking numbers no screen currently collects. They stay in the
87
+ * catalogue because they are still STORED and still returned, and anything
88
+ * rebuilding a `body` object has to round-trip them or it will erase them —
89
+ * see `buildProfileBody` in the web client, which replaces the whole object.
90
+ */
91
+ export declare const MEASURE_FIELDS: readonly MeasureField[];
92
+ /** The five the member-facing editor asks for, in screen order. */
93
+ export declare const MEMBER_BODY_MEASURE_KEYS: readonly ["height", "weight", "neck", "waist", "hip"];
94
+ //# sourceMappingURL=body-metrics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"body-metrics.d.ts","sourceRoot":"","sources":["../src/body-metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAI1C;;;;;GAKG;AACH,MAAM,WAAW,IAAI;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,IAAI,EAAE,KAAK,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;CACxC;AAID,yEAAyE;AACzE,wBAAgB,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAIvF;AAED,uCAAuC;AACvC,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAKzC;AAED,uEAAuE;AACvE,eAAO,MAAM,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,CAAgB,CAAC;AAC1D,eAAO,MAAM,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,CAAY,CAAC;AAIpD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,GAAG,IAAI,EACvB,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,OAAO,EAAE,MAAM,GAAG,IAAI,EACtB,MAAM,EAAE,MAAM,GAAG,IAAI,GACpB,MAAM,GAAG,IAAI,CAiBf;AAED,iDAAiD;AACjD,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,IAAI,CAa5D;AAED,wEAAwE;AACxE,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAE/D;AACD,eAAO,MAAM,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,CAAW,CAAC;AAIxD;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE1D,kFAAkF;AAClF,MAAM,WAAW,YAAY;IAC3B,2EAA2E;IAC3E,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,WAAW,CAAC;IAC1B,QAAQ,EAAE,YAAY,CAAC;CACxB;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,cAAc,EAAE,SAAS,YAAY,EAWxC,CAAC;AAEX,mEAAmE;AACnE,eAAO,MAAM,wBAAwB,uDAAwD,CAAC"}
@@ -0,0 +1,133 @@
1
+ /**
2
+ * gymmonk-schema — Body metrics: the derived numbers and the field catalogue
3
+ * ==========================================================================
4
+ * BMI and body-fat % are DERIVED, never persisted — `member-profile` stores
5
+ * only the raw cm / kg. That makes the formulas and their thresholds a contract
6
+ * rather than a rendering detail: two surfaces computing a member's BMI
7
+ * slightly differently would show that member two different numbers for the
8
+ * same body, and neither would be wrong on its own terms.
9
+ *
10
+ * This module therefore owns, for BOTH the client and the API:
11
+ * - the two formulas (BMI, U.S. Navy body fat)
12
+ * - the bands each result falls into, and the gauge bounds they are drawn on
13
+ * - the catalogue of measurements a member can record, and which raw field
14
+ * each one writes to
15
+ *
16
+ * It lived in the web client (`features/onboarding/lib/body-metrics`, later
17
+ * `features/profile/lib`), which meant the backend had no way to reach it and
18
+ * a second implementation would have been the only option the day anything
19
+ * server-side needed a BMI.
20
+ *
21
+ * What is deliberately NOT here: formatting. `"24 kg/m²"`, `"5′ 8″"` and
22
+ * `"157 lb"` are produced at the edge by the client, per this codebase's
23
+ * raw-values-format-at-edge rule. The unit KIND is here because it is part of
24
+ * what a measurement IS; turning it into a string is presentation.
25
+ *
26
+ * @module gymmonk-schema/body-metrics
27
+ */
28
+ // ─── BMI ─────────────────────────────────────────────────────────────────────
29
+ /** BMI = kg / m². Rounded to 1 decimal. Null until both inputs exist. */
30
+ export function calcBmi(heightCm, weightKg) {
31
+ if (!heightCm || !weightKg || heightCm <= 0)
32
+ return null;
33
+ const m = heightCm / 100;
34
+ return Math.round((weightKg / (m * m)) * 10) / 10;
35
+ }
36
+ /** WHO BMI band the value falls in. */
37
+ export function bmiTier(bmi) {
38
+ if (bmi < 18.5)
39
+ return { label: 'Underweight', tone: 'low' };
40
+ if (bmi < 25)
41
+ return { label: 'Normal', tone: 'good' };
42
+ if (bmi < 30)
43
+ return { label: 'Overweight', tone: 'warn' };
44
+ return { label: 'Obese', tone: 'high' };
45
+ }
46
+ /** WHO healthy BMI range, and sensible visual bounds for the gauge. */
47
+ export const BMI_HEALTHY = [18.5, 24.9];
48
+ export const BMI_GAUGE = [15, 40];
49
+ // ─── Body fat (U.S. Navy method) ─────────────────────────────────────────────
50
+ /**
51
+ * U.S. Navy body-fat % from circumferences (cm). Men use neck + waist; women
52
+ * also use hips. Returns null until the needed inputs are present and valid.
53
+ *
54
+ * HEIGHT is an input to both variants, which is easy to miss: a member who
55
+ * filled in neck and waist but no height gets nothing back, and the screen has
56
+ * to say so rather than render an empty card.
57
+ */
58
+ export function calcBodyFatNavy(gender, heightCm, neckCm, waistCm, hipsCm) {
59
+ if (!heightCm || !neckCm || !waistCm)
60
+ return null;
61
+ let bf;
62
+ if (gender === 'female') {
63
+ if (!hipsCm)
64
+ return null;
65
+ const inner = waistCm + hipsCm - neckCm;
66
+ if (inner <= 0)
67
+ return null;
68
+ bf = 495 / (1.29579 - 0.35004 * Math.log10(inner) + 0.221 * Math.log10(heightCm)) - 450;
69
+ }
70
+ else {
71
+ const inner = waistCm - neckCm;
72
+ if (inner <= 0)
73
+ return null;
74
+ bf = 495 / (1.0324 - 0.19077 * Math.log10(inner) + 0.15456 * Math.log10(heightCm)) - 450;
75
+ }
76
+ if (!Number.isFinite(bf) || bf <= 0)
77
+ return null;
78
+ return Math.round(bf * 10) / 10;
79
+ }
80
+ /** ACE body-fat categories (gender-specific). */
81
+ export function bodyFatTier(gender, bf) {
82
+ if (gender === 'female') {
83
+ if (bf < 14)
84
+ return { label: 'Essential', tone: 'low' };
85
+ if (bf < 21)
86
+ return { label: 'Athletic', tone: 'good' };
87
+ if (bf < 25)
88
+ return { label: 'Fitness', tone: 'good' };
89
+ if (bf < 32)
90
+ return { label: 'Average', tone: 'good' };
91
+ return { label: 'High', tone: 'high' };
92
+ }
93
+ if (bf < 6)
94
+ return { label: 'Essential', tone: 'low' };
95
+ if (bf < 14)
96
+ return { label: 'Athletic', tone: 'good' };
97
+ if (bf < 18)
98
+ return { label: 'Fitness', tone: 'good' };
99
+ if (bf < 25)
100
+ return { label: 'Average', tone: 'good' };
101
+ return { label: 'High', tone: 'high' };
102
+ }
103
+ /** Healthy body-fat range (athletic → acceptable), and gauge bounds. */
104
+ export function bodyFatHealthy(gender) {
105
+ return gender === 'female' ? [14, 31] : [6, 24];
106
+ }
107
+ export const BODY_FAT_GAUGE = [0, 45];
108
+ /**
109
+ * Every measurement the record can hold, in the order a body reads top to
110
+ * bottom.
111
+ *
112
+ * TEN, while the member-facing editor asks for five. Height and weight give
113
+ * BMI; neck, waist and hips give the body-fat estimate; the remaining five are
114
+ * progress-tracking numbers no screen currently collects. They stay in the
115
+ * catalogue because they are still STORED and still returned, and anything
116
+ * rebuilding a `body` object has to round-trip them or it will erase them —
117
+ * see `buildProfileBody` in the web client, which replaces the whole object.
118
+ */
119
+ export const MEASURE_FIELDS = [
120
+ { key: 'height', label: 'Height', unit: 'cm', metric: 'heightCm', imperial: 'height' },
121
+ { key: 'weight', label: 'Weight', unit: 'kg', metric: 'weightKg', imperial: 'weight' },
122
+ { key: 'neck', label: 'Neck', unit: 'cm', metric: 'neckCm', imperial: 'length' },
123
+ { key: 'forearm', label: 'Forearm', unit: 'cm', metric: 'forearmCm', imperial: 'length' },
124
+ { key: 'arms', label: 'Arms', unit: 'cm', metric: 'armsCm', imperial: 'length' },
125
+ { key: 'chest', label: 'Chest', unit: 'cm', metric: 'chestCm', imperial: 'length' },
126
+ { key: 'waist', label: 'Waist', unit: 'cm', metric: 'waistCm', imperial: 'length' },
127
+ { key: 'hip', label: 'Hip', unit: 'cm', metric: 'hipsCm', imperial: 'length' },
128
+ { key: 'thigh', label: 'Thigh', unit: 'cm', metric: 'thighCm', imperial: 'length' },
129
+ { key: 'calf', label: 'Calf', unit: 'cm', metric: 'calfCm', imperial: 'length' },
130
+ ];
131
+ /** The five the member-facing editor asks for, in screen order. */
132
+ export const MEMBER_BODY_MEASURE_KEYS = ['height', 'weight', 'neck', 'waist', 'hip'];
133
+ //# sourceMappingURL=body-metrics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"body-metrics.js","sourceRoot":"","sources":["../src/body-metrics.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Body metrics: the derived numbers and the field catalogue\n * ==========================================================================\n * BMI and body-fat % are DERIVED, never persisted — `member-profile` stores\n * only the raw cm / kg. That makes the formulas and their thresholds a contract\n * rather than a rendering detail: two surfaces computing a member's BMI\n * slightly differently would show that member two different numbers for the\n * same body, and neither would be wrong on its own terms.\n *\n * This module therefore owns, for BOTH the client and the API:\n * - the two formulas (BMI, U.S. Navy body fat)\n * - the bands each result falls into, and the gauge bounds they are drawn on\n * - the catalogue of measurements a member can record, and which raw field\n * each one writes to\n *\n * It lived in the web client (`features/onboarding/lib/body-metrics`, later\n * `features/profile/lib`), which meant the backend had no way to reach it and\n * a second implementation would have been the only option the day anything\n * server-side needed a BMI.\n *\n * What is deliberately NOT here: formatting. `\"24 kg/m²\"`, `\"5′ 8″\"` and\n * `\"157 lb\"` are produced at the edge by the client, per this codebase's\n * raw-values-format-at-edge rule. The unit KIND is here because it is part of\n * what a measurement IS; turning it into a string is presentation.\n *\n * @module gymmonk-schema/body-metrics\n */\n\nimport type { BodyMetrics } from './member-profile.js';\nimport type { Gender } from './shared.js';\n\n// ─── Bands ───────────────────────────────────────────────────────────────────\n\n/**\n * Where a derived number sits against its healthy range.\n *\n * `tone` is a semantic band, not a colour: the client maps it to teal / amber /\n * red. Naming the colour here would put a design decision in the contract.\n */\nexport interface Tier {\n label: string;\n /** good = healthy, low/warn = caution, high = out of range. */\n tone: 'low' | 'good' | 'warn' | 'high';\n}\n\n// ─── BMI ─────────────────────────────────────────────────────────────────────\n\n/** BMI = kg / m². Rounded to 1 decimal. Null until both inputs exist. */\nexport function calcBmi(heightCm: number | null, weightKg: number | null): number | null {\n if (!heightCm || !weightKg || heightCm <= 0) return null;\n const m = heightCm / 100;\n return Math.round((weightKg / (m * m)) * 10) / 10;\n}\n\n/** WHO BMI band the value falls in. */\nexport function bmiTier(bmi: number): Tier {\n if (bmi < 18.5) return { label: 'Underweight', tone: 'low' };\n if (bmi < 25) return { label: 'Normal', tone: 'good' };\n if (bmi < 30) return { label: 'Overweight', tone: 'warn' };\n return { label: 'Obese', tone: 'high' };\n}\n\n/** WHO healthy BMI range, and sensible visual bounds for the gauge. */\nexport const BMI_HEALTHY: [number, number] = [18.5, 24.9];\nexport const BMI_GAUGE: [number, number] = [15, 40];\n\n// ─── Body fat (U.S. Navy method) ─────────────────────────────────────────────\n\n/**\n * U.S. Navy body-fat % from circumferences (cm). Men use neck + waist; women\n * also use hips. Returns null until the needed inputs are present and valid.\n *\n * HEIGHT is an input to both variants, which is easy to miss: a member who\n * filled in neck and waist but no height gets nothing back, and the screen has\n * to say so rather than render an empty card.\n */\nexport function calcBodyFatNavy(\n gender: Gender,\n heightCm: number | null,\n neckCm: number | null,\n waistCm: number | null,\n hipsCm: number | null,\n): number | null {\n if (!heightCm || !neckCm || !waistCm) return null;\n\n let bf: number;\n if (gender === 'female') {\n if (!hipsCm) return null;\n const inner = waistCm + hipsCm - neckCm;\n if (inner <= 0) return null;\n bf = 495 / (1.29579 - 0.35004 * Math.log10(inner) + 0.221 * Math.log10(heightCm)) - 450;\n } else {\n const inner = waistCm - neckCm;\n if (inner <= 0) return null;\n bf = 495 / (1.0324 - 0.19077 * Math.log10(inner) + 0.15456 * Math.log10(heightCm)) - 450;\n }\n\n if (!Number.isFinite(bf) || bf <= 0) return null;\n return Math.round(bf * 10) / 10;\n}\n\n/** ACE body-fat categories (gender-specific). */\nexport function bodyFatTier(gender: Gender, bf: number): Tier {\n if (gender === 'female') {\n if (bf < 14) return { label: 'Essential', tone: 'low' };\n if (bf < 21) return { label: 'Athletic', tone: 'good' };\n if (bf < 25) return { label: 'Fitness', tone: 'good' };\n if (bf < 32) return { label: 'Average', tone: 'good' };\n return { label: 'High', tone: 'high' };\n }\n if (bf < 6) return { label: 'Essential', tone: 'low' };\n if (bf < 14) return { label: 'Athletic', tone: 'good' };\n if (bf < 18) return { label: 'Fitness', tone: 'good' };\n if (bf < 25) return { label: 'Average', tone: 'good' };\n return { label: 'High', tone: 'high' };\n}\n\n/** Healthy body-fat range (athletic → acceptable), and gauge bounds. */\nexport function bodyFatHealthy(gender: Gender): [number, number] {\n return gender === 'female' ? [14, 31] : [6, 24];\n}\nexport const BODY_FAT_GAUGE: [number, number] = [0, 45];\n\n// ─── The measurement catalogue ───────────────────────────────────────────────\n\n/**\n * Which imperial reference a measurement is shown against.\n *\n * `height` is the only one people express in feet — a neck or waist in feet\n * would be absurd, so every other length falls back to plain inches. The\n * client turns this into `5′ 8″` or `34 in`; the kind itself is part of the\n * measurement's definition and belongs with it.\n */\nexport type ImperialKind = 'height' | 'length' | 'weight';\n\n/** A measurement a member can record: its label, its unit, and where it lands. */\nexport interface MeasureField {\n /** Stable form key. NOTE `hip` maps to `hipsCm` — the two names differ. */\n key: string;\n label: string;\n unit: string;\n metric: keyof BodyMetrics;\n imperial: ImperialKind;\n}\n\n/**\n * Every measurement the record can hold, in the order a body reads top to\n * bottom.\n *\n * TEN, while the member-facing editor asks for five. Height and weight give\n * BMI; neck, waist and hips give the body-fat estimate; the remaining five are\n * progress-tracking numbers no screen currently collects. They stay in the\n * catalogue because they are still STORED and still returned, and anything\n * rebuilding a `body` object has to round-trip them or it will erase them —\n * see `buildProfileBody` in the web client, which replaces the whole object.\n */\nexport const MEASURE_FIELDS: readonly MeasureField[] = [\n { key: 'height', label: 'Height', unit: 'cm', metric: 'heightCm', imperial: 'height' },\n { key: 'weight', label: 'Weight', unit: 'kg', metric: 'weightKg', imperial: 'weight' },\n { key: 'neck', label: 'Neck', unit: 'cm', metric: 'neckCm', imperial: 'length' },\n { key: 'forearm', label: 'Forearm', unit: 'cm', metric: 'forearmCm', imperial: 'length' },\n { key: 'arms', label: 'Arms', unit: 'cm', metric: 'armsCm', imperial: 'length' },\n { key: 'chest', label: 'Chest', unit: 'cm', metric: 'chestCm', imperial: 'length' },\n { key: 'waist', label: 'Waist', unit: 'cm', metric: 'waistCm', imperial: 'length' },\n { key: 'hip', label: 'Hip', unit: 'cm', metric: 'hipsCm', imperial: 'length' },\n { key: 'thigh', label: 'Thigh', unit: 'cm', metric: 'thighCm', imperial: 'length' },\n { key: 'calf', label: 'Calf', unit: 'cm', metric: 'calfCm', imperial: 'length' },\n] as const;\n\n/** The five the member-facing editor asks for, in screen order. */\nexport const MEMBER_BODY_MEASURE_KEYS = ['height', 'weight', 'neck', 'waist', 'hip'] as const;\n"]}
package/dist/common.d.ts CHANGED
@@ -92,4 +92,45 @@ export declare function ageInYears(year: number, month: number, day: number): nu
92
92
  */
93
93
  export declare const priceInrSchema: z.ZodNumber;
94
94
  export type PriceInr = z.infer<typeof priceInrSchema>;
95
+ /**
96
+ * Split one typed name into the two fields a `User` actually stores.
97
+ *
98
+ * Every screen that asks for a name asks for it in ONE box — onboarding step 1
99
+ * and the profile's Personal Details editor — because "first name / last name"
100
+ * is a storage detail, not a question a person can answer about their own name.
101
+ * The split therefore has to happen somewhere, and it has to happen the SAME
102
+ * way in both places: if onboarding split on the last space and the editor on
103
+ * the first, a member who opened the editor and pressed Save without touching
104
+ * anything would have their name silently re-cut.
105
+ *
106
+ * Splits on the FIRST space, so "Ram Kumar Singh" is `Ram` + `Kumar Singh`.
107
+ * Collapses runs of whitespace, so a double space or a trailing one cannot
108
+ * produce an empty surname or a leading blank.
109
+ *
110
+ * The inverse is the backend's `fullName(first, last)` and the web client's
111
+ * `fullName(user)`, which join the halves back with a single space — so a name
112
+ * that goes through this and back out again is unchanged.
113
+ */
114
+ export declare function splitFullName(value: string): {
115
+ firstName: string;
116
+ lastName: string;
117
+ };
118
+ /**
119
+ * The two stored name halves joined back into one string.
120
+ *
121
+ * The exact inverse of `splitFullName`, and deliberately sitting beside it:
122
+ * a name that goes through one and back through the other must come out
123
+ * unchanged, which is only guaranteed while both rules live in one file.
124
+ *
125
+ * Trims each half and drops an empty one, so a member with no surname reads as
126
+ * `"Rahul"` rather than `"Rahul "`. Returns `''` when there is nothing to
127
+ * join; callers pick their own fallback (the email, "Member", a dash), because
128
+ * the right one differs per screen.
129
+ *
130
+ * Both products call it through a thin local module — gymmonk-backend
131
+ * `shared/utils/user-name` and gymmonk-web-client `shared/lib/user-name` — so
132
+ * the many existing call sites keep their convenient signatures while there is
133
+ * only ONE rule about what a full name is.
134
+ */
135
+ export declare function joinFullName(firstName?: string | null, lastName?: string | null): string;
95
136
  //# sourceMappingURL=common.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;GAGG;AACH,eAAO,MAAM,sBAAsB,GAAI,OAAO,OAAO,KAAG,OAGvD,CAAC;AAEF,yEAAyE;AACzE,eAAO,MAAM,qBAAqB,GAAI,kBAAe,gDAC8B,CAAC;AAIpF,kDAAkD;AAClD,eAAO,MAAM,cAAc,aAGgB,CAAC;AAC5C,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAI5D,4DAA4D;AAC5D,eAAO,MAAM,gBAAgB;;;iBAG3B,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAI1D;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,mBAAmB;;;;EAAyC,CAAC;AAC1E,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAIhE;;;GAGG;AACH,eAAO,MAAM,WAAW,QAAiB,CAAC;AAC1C,eAAO,MAAM,aAAa,QAAY,CAAC;AAEvC;;;GAGG;AACH,eAAO,MAAM,WAAW,aAGqC,CAAC;AAC9D,MAAM,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEhD,6DAA6D;AAC7D,eAAO,MAAM,mBAAmB,6CAA+D,CAAC;AAEhG,wCAAwC;AACxC,eAAO,MAAM,aAAa,aAG8B,CAAC;AACzD,MAAM,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAEpD,6EAA6E;AAC7E,eAAO,MAAM,mBAAmB,4CAG/B,CAAC;AAIF,4DAA4D;AAC5D,eAAO,MAAM,aAAa,aAGoC,CAAC;AAC/D,MAAM,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAEpD,kEAAkE;AAClE,eAAO,MAAM,iBAAiB,kBAAmC,CAAC;AAClE,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D,sCAAsC;AACtC,eAAO,MAAM,eAAe,aAGmC,CAAC;AAChE,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAExD;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAK5E;AAED,+EAA+E;AAC/E,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAG3E;AAID;;;GAGG;AACH,eAAO,MAAM,cAAc,aAA0B,CAAC;AACtD,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC"}
1
+ {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;GAGG;AACH,eAAO,MAAM,sBAAsB,GAAI,OAAO,OAAO,KAAG,OAGvD,CAAC;AAEF,yEAAyE;AACzE,eAAO,MAAM,qBAAqB,GAAI,kBAAe,gDAC8B,CAAC;AAIpF,kDAAkD;AAClD,eAAO,MAAM,cAAc,aAGgB,CAAC;AAC5C,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAI5D,4DAA4D;AAC5D,eAAO,MAAM,gBAAgB;;;iBAG3B,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAI1D;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,mBAAmB;;;;EAAyC,CAAC;AAC1E,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAIhE;;;GAGG;AACH,eAAO,MAAM,WAAW,QAAiB,CAAC;AAC1C,eAAO,MAAM,aAAa,QAAY,CAAC;AAEvC;;;GAGG;AACH,eAAO,MAAM,WAAW,aAGqC,CAAC;AAC9D,MAAM,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEhD,6DAA6D;AAC7D,eAAO,MAAM,mBAAmB,6CAA+D,CAAC;AAEhG,wCAAwC;AACxC,eAAO,MAAM,aAAa,aAG8B,CAAC;AACzD,MAAM,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAEpD,6EAA6E;AAC7E,eAAO,MAAM,mBAAmB,4CAG/B,CAAC;AAIF,4DAA4D;AAC5D,eAAO,MAAM,aAAa,aAGoC,CAAC;AAC/D,MAAM,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAEpD,kEAAkE;AAClE,eAAO,MAAM,iBAAiB,kBAAmC,CAAC;AAClE,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D,sCAAsC;AACtC,eAAO,MAAM,eAAe,aAGmC,CAAC;AAChE,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAExD;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAK5E;AAED,+EAA+E;AAC/E,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAG3E;AAID;;;GAGG;AACH,eAAO,MAAM,cAAc,aAA0B,CAAC;AACtD,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAItD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAKpF;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,YAAY,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAKxF"}
package/dist/common.js CHANGED
@@ -111,4 +111,54 @@ export function ageInYears(year, month, day) {
111
111
  * `₹` and grouping at the edge, never store the formatted string.
112
112
  */
113
113
  export const priceInrSchema = z.number().int().min(0);
114
+ // ─── Names ───────────────────────────────────────────────────────────────────
115
+ /**
116
+ * Split one typed name into the two fields a `User` actually stores.
117
+ *
118
+ * Every screen that asks for a name asks for it in ONE box — onboarding step 1
119
+ * and the profile's Personal Details editor — because "first name / last name"
120
+ * is a storage detail, not a question a person can answer about their own name.
121
+ * The split therefore has to happen somewhere, and it has to happen the SAME
122
+ * way in both places: if onboarding split on the last space and the editor on
123
+ * the first, a member who opened the editor and pressed Save without touching
124
+ * anything would have their name silently re-cut.
125
+ *
126
+ * Splits on the FIRST space, so "Ram Kumar Singh" is `Ram` + `Kumar Singh`.
127
+ * Collapses runs of whitespace, so a double space or a trailing one cannot
128
+ * produce an empty surname or a leading blank.
129
+ *
130
+ * The inverse is the backend's `fullName(first, last)` and the web client's
131
+ * `fullName(user)`, which join the halves back with a single space — so a name
132
+ * that goes through this and back out again is unchanged.
133
+ */
134
+ export function splitFullName(value) {
135
+ const parts = value.trim().split(/\s+/).filter(Boolean);
136
+ if (parts.length === 0)
137
+ return { firstName: '', lastName: '' };
138
+ const [firstName, ...rest] = parts;
139
+ return { firstName: firstName, lastName: rest.join(' ') };
140
+ }
141
+ /**
142
+ * The two stored name halves joined back into one string.
143
+ *
144
+ * The exact inverse of `splitFullName`, and deliberately sitting beside it:
145
+ * a name that goes through one and back through the other must come out
146
+ * unchanged, which is only guaranteed while both rules live in one file.
147
+ *
148
+ * Trims each half and drops an empty one, so a member with no surname reads as
149
+ * `"Rahul"` rather than `"Rahul "`. Returns `''` when there is nothing to
150
+ * join; callers pick their own fallback (the email, "Member", a dash), because
151
+ * the right one differs per screen.
152
+ *
153
+ * Both products call it through a thin local module — gymmonk-backend
154
+ * `shared/utils/user-name` and gymmonk-web-client `shared/lib/user-name` — so
155
+ * the many existing call sites keep their convenient signatures while there is
156
+ * only ONE rule about what a full name is.
157
+ */
158
+ export function joinFullName(firstName, lastName) {
159
+ return [firstName, lastName]
160
+ .map((part) => (part ?? '').trim())
161
+ .filter(Boolean)
162
+ .join(' ');
163
+ }
114
164
  //# sourceMappingURL=common.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"common.js","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,gFAAgF;AAEhF;;;GAGG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,KAAc,EAAW,EAAE;IAChE,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,SAAS,CAAC;IACvE,OAAO,KAAK,CAAC;AACf,CAAC,CAAC;AAEF,yEAAyE;AACzE,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,SAAS,GAAG,GAAG,EAAE,EAAE,CACvD,CAAC,CAAC,UAAU,CAAC,sBAAsB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC;AAEpF,gFAAgF;AAEhF,kDAAkD;AAClD,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC;KAC5B,MAAM,EAAE;KACR,IAAI,EAAE;KACN,KAAK,CAAC,mBAAmB,EAAE,YAAY,CAAC,CAAC;AAG5C,gFAAgF;AAEhF,4DAA4D;AAC5D,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC;IACvC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;IAC/C,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC;CAC3D,CAAC,CAAC;AAGH,gFAAgF;AAEhF;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC,CAAC;AAG1E,gFAAgF;AAEhF;;;GAGG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,cAAc,CAAC;AAC1C,MAAM,CAAC,MAAM,aAAa,GAAG,SAAS,CAAC;AAEvC;;;GAGG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC;KACzB,MAAM,EAAE;KACR,IAAI,EAAE;KACN,KAAK,CAAC,WAAW,EAAE,sCAAsC,CAAC,CAAC;AAG9D,6DAA6D;AAC7D,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,UAAU,CAAC,sBAAsB,EAAE,WAAW,CAAC,QAAQ,EAAE,CAAC,CAAC;AAEhG,wCAAwC;AACxC,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC;KAC3B,MAAM,EAAE;KACR,IAAI,EAAE;KACN,KAAK,CAAC,aAAa,EAAE,+BAA+B,CAAC,CAAC;AAGzD,6EAA6E;AAC7E,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,UAAU,CAC7C,sBAAsB,EACtB,CAAC,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC,QAAQ,EAAE,CAC1C,CAAC;AAEF,gFAAgF;AAEhF,4DAA4D;AAC5D,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC;KAC3B,MAAM,EAAE;KACR,IAAI,EAAE;KACN,KAAK,CAAC,qBAAqB,EAAE,6BAA6B,CAAC,CAAC;AAG/D,kEAAkE;AAClE,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;AAGlE,sCAAsC;AACtC,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC;KAC7B,MAAM,EAAE;KACR,IAAI,EAAE;KACN,KAAK,CAAC,2BAA2B,EAAE,wBAAwB,CAAC,CAAC;AAGhE;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,IAAY,EAAE,KAAa,EAAE,GAAW;IACjE,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;IACtD,OAAO,CACL,IAAI,CAAC,cAAc,EAAE,KAAK,IAAI,IAAI,IAAI,CAAC,WAAW,EAAE,KAAK,KAAK,GAAG,CAAC,IAAI,IAAI,CAAC,UAAU,EAAE,KAAK,GAAG,CAChG,CAAC;AACJ,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,UAAU,CAAC,IAAY,EAAE,KAAa,EAAE,GAAW;IACjE,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;IAC5C,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;AAC9D,CAAC;AAED,gFAAgF;AAEhF;;;GAGG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC","sourcesContent":["/**\n * gymmonk-schema — Shared helpers and primitives\n * ==============================================\n * The stable building blocks reused across every GymMonk domain schema.\n * Mirrors the `dating-schema` / `near-me-schema` idiom: pure Zod, const-object\n * enums fed to `z.enum(...)`, NodeNext `.js` import specifiers.\n *\n * @module gymmonk-schema/common\n */\n\nimport { z } from 'zod';\n\n// ─── String helpers ──────────────────────────────────────────────────────────\n\n/**\n * Converts empty / whitespace-only strings to `undefined`\n * so Zod `.optional()` treats them as \"not provided\".\n */\nexport const emptyStringToUndefined = (value: unknown): unknown => {\n if (typeof value === 'string' && value.trim() === '') return undefined;\n return value;\n};\n\n/** Optional trimmed string. Empty strings are coerced to `undefined`. */\nexport const optionalTrimmedString = (maxLength = 100) =>\n z.preprocess(emptyStringToUndefined, z.string().trim().max(maxLength).optional());\n\n// ─── Identifiers ─────────────────────────────────────────────────────────────\n\n/** A 24-character hex MongoDB ObjectId string. */\nexport const objectIdSchema = z\n .string()\n .trim()\n .regex(/^[0-9a-fA-F]{24}$/, 'Invalid id');\nexport type ObjectIdString = z.infer<typeof objectIdSchema>;\n\n// ─── Pagination ──────────────────────────────────────────────────────────────\n\n/** Pagination query params shared across list endpoints. */\nexport const paginationSchema = z.object({\n page: z.coerce.number().int().min(1).default(1),\n limit: z.coerce.number().int().min(1).max(100).default(20),\n});\nexport type Pagination = z.infer<typeof paginationSchema>;\n\n// ─── Soft-delete visibility ──────────────────────────────────────────────────\n\n/**\n * How a list query treats soft-deleted rows.\n *\n * THREE states, not a boolean. A boolean can only say \"live\" or \"live +\n * deleted\", which means a console offering a \"Deleted\" filter has to fetch both\n * and drop the live ones client-side — and that breaks the moment the list is\n * paginated: page 1 of \"everything\" may contain no deleted rows at all, so the\n * screen shows an empty list under a filter that matches dozens of records\n * further down. Only the server can answer \"just the deleted ones\".\n *\n * `exclude` the default, and what every ordinary caller wants\n * `include` live and deleted together, for a search that should find both\n * `only` the restore queue\n *\n * Lives here rather than beside one directory because both the people and the\n * gym listings take it, and they must behave identically.\n */\nexport const deletedFilterSchema = z.enum(['exclude', 'include', 'only']);\nexport type DeletedFilter = z.infer<typeof deletedFilterSchema>;\n\n// ─── Contact primitives (India) ──────────────────────────────────────────────\n\n/**\n * The raw patterns, exported so the onboarding FORM schemas validate a typed\n * value exactly the way the API schemas below do — one rule, both sides.\n */\nexport const PHONE_REGEX = /^[6-9]\\d{9}$/;\nexport const PINCODE_REGEX = /^\\d{6}$/;\n\n/**\n * A bare 10-digit Indian mobile number. Stored RAW (no `+91`) app-wide — the\n * country code is display-only. Mirrors the jansathi phone-number standard.\n */\nexport const phoneSchema = z\n .string()\n .trim()\n .regex(PHONE_REGEX, 'Enter a valid 10-digit mobile number');\nexport type Phone = z.infer<typeof phoneSchema>;\n\n/** Optional phone — empty/whitespace becomes `undefined`. */\nexport const optionalPhoneSchema = z.preprocess(emptyStringToUndefined, phoneSchema.optional());\n\n/** A 6-digit Indian postal PIN code. */\nexport const pincodeSchema = z\n .string()\n .trim()\n .regex(PINCODE_REGEX, 'Enter a valid 6-digit pincode');\nexport type Pincode = z.infer<typeof pincodeSchema>;\n\n/** Optional email — empty/whitespace becomes `undefined`, else validated. */\nexport const optionalEmailSchema = z.preprocess(\n emptyStringToUndefined,\n z.email('Enter a valid email').optional(),\n);\n\n// ─── Date & time primitives ──────────────────────────────────────────────────\n\n/** A calendar date with no time component, `YYYY-MM-DD`. */\nexport const isoDateSchema = z\n .string()\n .trim()\n .regex(/^\\d{4}-\\d{2}-\\d{2}$/, 'Expected an YYYY-MM-DD date');\nexport type IsoDate = z.infer<typeof isoDateSchema>;\n\n/** A full ISO-8601 timestamp, e.g. `2026-08-06T09:30:00.000Z`. */\nexport const isoDateTimeSchema = z.iso.datetime({ offset: true });\nexport type IsoDateTime = z.infer<typeof isoDateTimeSchema>;\n\n/** Time of day in 24-hour `HH:MM`. */\nexport const timeOfDaySchema = z\n .string()\n .trim()\n .regex(/^([01]\\d|2[0-3]):[0-5]\\d$/, 'Expected an HH:MM time');\nexport type TimeOfDay = z.infer<typeof timeOfDaySchema>;\n\n/**\n * `Date` rolls overflow forward (Feb 31 → Mar 3), so a real date is one whose\n * parts survive the round-trip.\n *\n * Lives here rather than beside one form's rules because every date-of-birth\n * field in the product needs it — the onboarding wizards and the owner's desk\n * entry forms all check the same thing.\n */\nexport function isRealDate(year: number, month: number, day: number): boolean {\n const date = new Date(Date.UTC(year, month - 1, day));\n return (\n date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day\n );\n}\n\n/** Whole years elapsed since the given Y/M/D, in UTC. Negative when future. */\nexport function ageInYears(year: number, month: number, day: number): number {\n const date = Date.UTC(year, month - 1, day);\n return (Date.now() - date) / (365.25 * 24 * 60 * 60 * 1000);\n}\n\n// ─── Money ───────────────────────────────────────────────────────────────────\n\n/**\n * A whole-rupee (INR) amount. GymMonk prices are whole rupees; format with a\n * `₹` and grouping at the edge, never store the formatted string.\n */\nexport const priceInrSchema = z.number().int().min(0);\nexport type PriceInr = z.infer<typeof priceInrSchema>;\n"]}
1
+ {"version":3,"file":"common.js","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,gFAAgF;AAEhF;;;GAGG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,KAAc,EAAW,EAAE;IAChE,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,SAAS,CAAC;IACvE,OAAO,KAAK,CAAC;AACf,CAAC,CAAC;AAEF,yEAAyE;AACzE,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,SAAS,GAAG,GAAG,EAAE,EAAE,CACvD,CAAC,CAAC,UAAU,CAAC,sBAAsB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC;AAEpF,gFAAgF;AAEhF,kDAAkD;AAClD,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC;KAC5B,MAAM,EAAE;KACR,IAAI,EAAE;KACN,KAAK,CAAC,mBAAmB,EAAE,YAAY,CAAC,CAAC;AAG5C,gFAAgF;AAEhF,4DAA4D;AAC5D,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC;IACvC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;IAC/C,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC;CAC3D,CAAC,CAAC;AAGH,gFAAgF;AAEhF;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC,CAAC;AAG1E,gFAAgF;AAEhF;;;GAGG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,cAAc,CAAC;AAC1C,MAAM,CAAC,MAAM,aAAa,GAAG,SAAS,CAAC;AAEvC;;;GAGG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC;KACzB,MAAM,EAAE;KACR,IAAI,EAAE;KACN,KAAK,CAAC,WAAW,EAAE,sCAAsC,CAAC,CAAC;AAG9D,6DAA6D;AAC7D,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,UAAU,CAAC,sBAAsB,EAAE,WAAW,CAAC,QAAQ,EAAE,CAAC,CAAC;AAEhG,wCAAwC;AACxC,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC;KAC3B,MAAM,EAAE;KACR,IAAI,EAAE;KACN,KAAK,CAAC,aAAa,EAAE,+BAA+B,CAAC,CAAC;AAGzD,6EAA6E;AAC7E,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,UAAU,CAC7C,sBAAsB,EACtB,CAAC,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC,QAAQ,EAAE,CAC1C,CAAC;AAEF,gFAAgF;AAEhF,4DAA4D;AAC5D,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC;KAC3B,MAAM,EAAE;KACR,IAAI,EAAE;KACN,KAAK,CAAC,qBAAqB,EAAE,6BAA6B,CAAC,CAAC;AAG/D,kEAAkE;AAClE,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;AAGlE,sCAAsC;AACtC,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC;KAC7B,MAAM,EAAE;KACR,IAAI,EAAE;KACN,KAAK,CAAC,2BAA2B,EAAE,wBAAwB,CAAC,CAAC;AAGhE;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,IAAY,EAAE,KAAa,EAAE,GAAW;IACjE,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;IACtD,OAAO,CACL,IAAI,CAAC,cAAc,EAAE,KAAK,IAAI,IAAI,IAAI,CAAC,WAAW,EAAE,KAAK,KAAK,GAAG,CAAC,IAAI,IAAI,CAAC,UAAU,EAAE,KAAK,GAAG,CAChG,CAAC;AACJ,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,UAAU,CAAC,IAAY,EAAE,KAAa,EAAE,GAAW;IACjE,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;IAC5C,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;AAC9D,CAAC;AAED,gFAAgF;AAEhF;;;GAGG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;AAGtD,gFAAgF;AAEhF;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,aAAa,CAAC,KAAa;IACzC,MAAM,KAAK,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACxD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,SAAS,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC;IAC/D,MAAM,CAAC,SAAS,EAAE,GAAG,IAAI,CAAC,GAAG,KAAK,CAAC;IACnC,OAAO,EAAE,SAAS,EAAE,SAAmB,EAAE,QAAQ,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;AACtE,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,YAAY,CAAC,SAAyB,EAAE,QAAwB;IAC9E,OAAO,CAAC,SAAS,EAAE,QAAQ,CAAC;SACzB,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;SAClC,MAAM,CAAC,OAAO,CAAC;SACf,IAAI,CAAC,GAAG,CAAC,CAAC;AACf,CAAC","sourcesContent":["/**\n * gymmonk-schema — Shared helpers and primitives\n * ==============================================\n * The stable building blocks reused across every GymMonk domain schema.\n * Mirrors the `dating-schema` / `near-me-schema` idiom: pure Zod, const-object\n * enums fed to `z.enum(...)`, NodeNext `.js` import specifiers.\n *\n * @module gymmonk-schema/common\n */\n\nimport { z } from 'zod';\n\n// ─── String helpers ──────────────────────────────────────────────────────────\n\n/**\n * Converts empty / whitespace-only strings to `undefined`\n * so Zod `.optional()` treats them as \"not provided\".\n */\nexport const emptyStringToUndefined = (value: unknown): unknown => {\n if (typeof value === 'string' && value.trim() === '') return undefined;\n return value;\n};\n\n/** Optional trimmed string. Empty strings are coerced to `undefined`. */\nexport const optionalTrimmedString = (maxLength = 100) =>\n z.preprocess(emptyStringToUndefined, z.string().trim().max(maxLength).optional());\n\n// ─── Identifiers ─────────────────────────────────────────────────────────────\n\n/** A 24-character hex MongoDB ObjectId string. */\nexport const objectIdSchema = z\n .string()\n .trim()\n .regex(/^[0-9a-fA-F]{24}$/, 'Invalid id');\nexport type ObjectIdString = z.infer<typeof objectIdSchema>;\n\n// ─── Pagination ──────────────────────────────────────────────────────────────\n\n/** Pagination query params shared across list endpoints. */\nexport const paginationSchema = z.object({\n page: z.coerce.number().int().min(1).default(1),\n limit: z.coerce.number().int().min(1).max(100).default(20),\n});\nexport type Pagination = z.infer<typeof paginationSchema>;\n\n// ─── Soft-delete visibility ──────────────────────────────────────────────────\n\n/**\n * How a list query treats soft-deleted rows.\n *\n * THREE states, not a boolean. A boolean can only say \"live\" or \"live +\n * deleted\", which means a console offering a \"Deleted\" filter has to fetch both\n * and drop the live ones client-side — and that breaks the moment the list is\n * paginated: page 1 of \"everything\" may contain no deleted rows at all, so the\n * screen shows an empty list under a filter that matches dozens of records\n * further down. Only the server can answer \"just the deleted ones\".\n *\n * `exclude` the default, and what every ordinary caller wants\n * `include` live and deleted together, for a search that should find both\n * `only` the restore queue\n *\n * Lives here rather than beside one directory because both the people and the\n * gym listings take it, and they must behave identically.\n */\nexport const deletedFilterSchema = z.enum(['exclude', 'include', 'only']);\nexport type DeletedFilter = z.infer<typeof deletedFilterSchema>;\n\n// ─── Contact primitives (India) ──────────────────────────────────────────────\n\n/**\n * The raw patterns, exported so the onboarding FORM schemas validate a typed\n * value exactly the way the API schemas below do — one rule, both sides.\n */\nexport const PHONE_REGEX = /^[6-9]\\d{9}$/;\nexport const PINCODE_REGEX = /^\\d{6}$/;\n\n/**\n * A bare 10-digit Indian mobile number. Stored RAW (no `+91`) app-wide — the\n * country code is display-only. Mirrors the jansathi phone-number standard.\n */\nexport const phoneSchema = z\n .string()\n .trim()\n .regex(PHONE_REGEX, 'Enter a valid 10-digit mobile number');\nexport type Phone = z.infer<typeof phoneSchema>;\n\n/** Optional phone — empty/whitespace becomes `undefined`. */\nexport const optionalPhoneSchema = z.preprocess(emptyStringToUndefined, phoneSchema.optional());\n\n/** A 6-digit Indian postal PIN code. */\nexport const pincodeSchema = z\n .string()\n .trim()\n .regex(PINCODE_REGEX, 'Enter a valid 6-digit pincode');\nexport type Pincode = z.infer<typeof pincodeSchema>;\n\n/** Optional email — empty/whitespace becomes `undefined`, else validated. */\nexport const optionalEmailSchema = z.preprocess(\n emptyStringToUndefined,\n z.email('Enter a valid email').optional(),\n);\n\n// ─── Date & time primitives ──────────────────────────────────────────────────\n\n/** A calendar date with no time component, `YYYY-MM-DD`. */\nexport const isoDateSchema = z\n .string()\n .trim()\n .regex(/^\\d{4}-\\d{2}-\\d{2}$/, 'Expected an YYYY-MM-DD date');\nexport type IsoDate = z.infer<typeof isoDateSchema>;\n\n/** A full ISO-8601 timestamp, e.g. `2026-08-06T09:30:00.000Z`. */\nexport const isoDateTimeSchema = z.iso.datetime({ offset: true });\nexport type IsoDateTime = z.infer<typeof isoDateTimeSchema>;\n\n/** Time of day in 24-hour `HH:MM`. */\nexport const timeOfDaySchema = z\n .string()\n .trim()\n .regex(/^([01]\\d|2[0-3]):[0-5]\\d$/, 'Expected an HH:MM time');\nexport type TimeOfDay = z.infer<typeof timeOfDaySchema>;\n\n/**\n * `Date` rolls overflow forward (Feb 31 → Mar 3), so a real date is one whose\n * parts survive the round-trip.\n *\n * Lives here rather than beside one form's rules because every date-of-birth\n * field in the product needs it — the onboarding wizards and the owner's desk\n * entry forms all check the same thing.\n */\nexport function isRealDate(year: number, month: number, day: number): boolean {\n const date = new Date(Date.UTC(year, month - 1, day));\n return (\n date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day\n );\n}\n\n/** Whole years elapsed since the given Y/M/D, in UTC. Negative when future. */\nexport function ageInYears(year: number, month: number, day: number): number {\n const date = Date.UTC(year, month - 1, day);\n return (Date.now() - date) / (365.25 * 24 * 60 * 60 * 1000);\n}\n\n// ─── Money ───────────────────────────────────────────────────────────────────\n\n/**\n * A whole-rupee (INR) amount. GymMonk prices are whole rupees; format with a\n * `₹` and grouping at the edge, never store the formatted string.\n */\nexport const priceInrSchema = z.number().int().min(0);\nexport type PriceInr = z.infer<typeof priceInrSchema>;\n\n// ─── Names ───────────────────────────────────────────────────────────────────\n\n/**\n * Split one typed name into the two fields a `User` actually stores.\n *\n * Every screen that asks for a name asks for it in ONE box — onboarding step 1\n * and the profile's Personal Details editor — because \"first name / last name\"\n * is a storage detail, not a question a person can answer about their own name.\n * The split therefore has to happen somewhere, and it has to happen the SAME\n * way in both places: if onboarding split on the last space and the editor on\n * the first, a member who opened the editor and pressed Save without touching\n * anything would have their name silently re-cut.\n *\n * Splits on the FIRST space, so \"Ram Kumar Singh\" is `Ram` + `Kumar Singh`.\n * Collapses runs of whitespace, so a double space or a trailing one cannot\n * produce an empty surname or a leading blank.\n *\n * The inverse is the backend's `fullName(first, last)` and the web client's\n * `fullName(user)`, which join the halves back with a single space — so a name\n * that goes through this and back out again is unchanged.\n */\nexport function splitFullName(value: string): { firstName: string; lastName: string } {\n const parts = value.trim().split(/\\s+/).filter(Boolean);\n if (parts.length === 0) return { firstName: '', lastName: '' };\n const [firstName, ...rest] = parts;\n return { firstName: firstName as string, lastName: rest.join(' ') };\n}\n\n/**\n * The two stored name halves joined back into one string.\n *\n * The exact inverse of `splitFullName`, and deliberately sitting beside it:\n * a name that goes through one and back through the other must come out\n * unchanged, which is only guaranteed while both rules live in one file.\n *\n * Trims each half and drops an empty one, so a member with no surname reads as\n * `\"Rahul\"` rather than `\"Rahul \"`. Returns `''` when there is nothing to\n * join; callers pick their own fallback (the email, \"Member\", a dash), because\n * the right one differs per screen.\n *\n * Both products call it through a thin local module — gymmonk-backend\n * `shared/utils/user-name` and gymmonk-web-client `shared/lib/user-name` — so\n * the many existing call sites keep their convenient signatures while there is\n * only ONE rule about what a full name is.\n */\nexport function joinFullName(firstName?: string | null, lastName?: string | null): string {\n return [firstName, lastName]\n .map((part) => (part ?? '').trim())\n .filter(Boolean)\n .join(' ');\n}\n"]}
@@ -10,9 +10,9 @@ import { z } from 'zod';
10
10
  export declare const EQUIPMENT_CATEGORIES: readonly ["cardio", "strength", "weight", "training", "recovery", "custom"];
11
11
  export declare const equipmentCategorySchema: z.ZodEnum<{
12
12
  custom: "custom";
13
+ weight: "weight";
13
14
  strength: "strength";
14
15
  cardio: "cardio";
15
- weight: "weight";
16
16
  training: "training";
17
17
  recovery: "recovery";
18
18
  }>;
@@ -22,9 +22,9 @@ export declare const equipmentSchema: z.ZodObject<{
22
22
  centerId: z.ZodString;
23
23
  category: z.ZodEnum<{
24
24
  custom: "custom";
25
+ weight: "weight";
25
26
  strength: "strength";
26
27
  cardio: "cardio";
27
- weight: "weight";
28
28
  training: "training";
29
29
  recovery: "recovery";
30
30
  }>;
@@ -39,9 +39,9 @@ export type Equipment = z.infer<typeof equipmentSchema>;
39
39
  export declare const equipmentCategorySummarySchema: z.ZodObject<{
40
40
  category: z.ZodEnum<{
41
41
  custom: "custom";
42
+ weight: "weight";
42
43
  strength: "strength";
43
44
  cardio: "cardio";
44
- weight: "weight";
45
45
  training: "training";
46
46
  recovery: "recovery";
47
47
  }>;
@@ -53,9 +53,9 @@ export declare const createEquipmentBodySchema: z.ZodObject<{
53
53
  centerId: z.ZodString;
54
54
  category: z.ZodEnum<{
55
55
  custom: "custom";
56
+ weight: "weight";
56
57
  strength: "strength";
57
58
  cardio: "cardio";
58
- weight: "weight";
59
59
  training: "training";
60
60
  recovery: "recovery";
61
61
  }>;
@@ -68,9 +68,9 @@ export declare const updateEquipmentBodySchema: z.ZodObject<{
68
68
  name: z.ZodOptional<z.ZodString>;
69
69
  category: z.ZodOptional<z.ZodEnum<{
70
70
  custom: "custom";
71
+ weight: "weight";
71
72
  strength: "strength";
72
73
  cardio: "cardio";
73
- weight: "weight";
74
74
  training: "training";
75
75
  recovery: "recovery";
76
76
  }>>;
@@ -101,9 +101,9 @@ export declare const EQUIPMENT_CATALOG: Record<EquipmentCategory, readonly strin
101
101
  export declare const equipmentQuantityEntrySchema: z.ZodObject<{
102
102
  category: z.ZodEnum<{
103
103
  custom: "custom";
104
+ weight: "weight";
104
105
  strength: "strength";
105
106
  cardio: "cardio";
106
- weight: "weight";
107
107
  training: "training";
108
108
  recovery: "recovery";
109
109
  }>;
@@ -116,9 +116,9 @@ export declare const setEquipmentQuantitiesBodySchema: z.ZodObject<{
116
116
  items: z.ZodArray<z.ZodObject<{
117
117
  category: z.ZodEnum<{
118
118
  custom: "custom";
119
+ weight: "weight";
119
120
  strength: "strength";
120
121
  cardio: "cardio";
121
- weight: "weight";
122
122
  training: "training";
123
123
  recovery: "recovery";
124
124
  }>;
@@ -131,9 +131,9 @@ export declare const equipmentQuerySchema: z.ZodObject<{
131
131
  centerId: z.ZodString;
132
132
  category: z.ZodOptional<z.ZodEnum<{
133
133
  custom: "custom";
134
+ weight: "weight";
134
135
  strength: "strength";
135
136
  cardio: "cardio";
136
- weight: "weight";
137
137
  training: "training";
138
138
  recovery: "recovery";
139
139
  }>>;
@@ -25,10 +25,10 @@ export type ExerciseCategory = z.infer<typeof exerciseCategorySchema>;
25
25
  /** Normalised superset of the target-muscle vocabulary used across the app. */
26
26
  export declare const MUSCLE_GROUPS: readonly ["chest", "back", "shoulders", "biceps", "triceps", "forearms", "quads", "hamstrings", "glutes", "calves", "legs", "core", "obliques", "lower-back", "full-body", "cardio"];
27
27
  export declare const muscleGroupSchema: z.ZodEnum<{
28
+ chest: "chest";
28
29
  cardio: "cardio";
29
30
  core: "core";
30
31
  "full-body": "full-body";
31
- chest: "chest";
32
32
  back: "back";
33
33
  shoulders: "shoulders";
34
34
  biceps: "biceps";
@@ -94,10 +94,10 @@ export declare const exerciseSchema: z.ZodObject<{
94
94
  youtubeId: z.ZodNullable<z.ZodString>;
95
95
  posterUrl: z.ZodNullable<z.ZodString>;
96
96
  targetMuscles: z.ZodArray<z.ZodEnum<{
97
+ chest: "chest";
97
98
  cardio: "cardio";
98
99
  core: "core";
99
100
  "full-body": "full-body";
100
- chest: "chest";
101
101
  back: "back";
102
102
  shoulders: "shoulders";
103
103
  biceps: "biceps";
@@ -143,10 +143,10 @@ export declare const createExerciseBodySchema: z.ZodObject<{
143
143
  thumbnailUrl: z.ZodPreprocess<z.ZodOptional<z.ZodString>>;
144
144
  posterUrl: z.ZodPreprocess<z.ZodOptional<z.ZodString>>;
145
145
  targetMuscles: z.ZodDefault<z.ZodArray<z.ZodEnum<{
146
+ chest: "chest";
146
147
  cardio: "cardio";
147
148
  core: "core";
148
149
  "full-body": "full-body";
149
- chest: "chest";
150
150
  back: "back";
151
151
  shoulders: "shoulders";
152
152
  biceps: "biceps";
@@ -189,10 +189,10 @@ export declare const updateExerciseBodySchema: z.ZodObject<{
189
189
  youtubeId: z.ZodOptional<z.ZodPreprocess<z.ZodOptional<z.ZodString>>>;
190
190
  posterUrl: z.ZodOptional<z.ZodPreprocess<z.ZodOptional<z.ZodString>>>;
191
191
  targetMuscles: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodEnum<{
192
+ chest: "chest";
192
193
  cardio: "cardio";
193
194
  core: "core";
194
195
  "full-body": "full-body";
195
- chest: "chest";
196
196
  back: "back";
197
197
  shoulders: "shoulders";
198
198
  biceps: "biceps";
@@ -8,19 +8,19 @@
8
8
  */
9
9
  import { z } from 'zod';
10
10
  export declare const feedbackRatingSchema: z.ZodEnum<{
11
+ good: "good";
11
12
  worst: "worst";
12
13
  bad: "bad";
13
14
  fair: "fair";
14
- good: "good";
15
15
  perfect: "perfect";
16
16
  }>;
17
17
  export type FeedbackRating = z.infer<typeof feedbackRatingSchema>;
18
18
  export declare const createFeedbackBodySchema: z.ZodObject<{
19
19
  rating: z.ZodEnum<{
20
+ good: "good";
20
21
  worst: "worst";
21
22
  bad: "bad";
22
23
  fair: "fair";
23
- good: "good";
24
24
  perfect: "perfect";
25
25
  }>;
26
26
  text: z.ZodDefault<z.ZodString>;
@@ -33,10 +33,10 @@ export declare const feedbackSchema: z.ZodObject<{
33
33
  centerId: z.ZodNullable<z.ZodString>;
34
34
  userId: z.ZodNullable<z.ZodString>;
35
35
  rating: z.ZodEnum<{
36
+ good: "good";
36
37
  worst: "worst";
37
38
  bad: "bad";
38
39
  fair: "fair";
39
- good: "good";
40
40
  perfect: "perfect";
41
41
  }>;
42
42
  text: z.ZodString;
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  export * from './add-person-form.js';
12
12
  export * from './announcement.js';
13
13
  export * from './area.js';
14
+ export * from './body-metrics.js';
14
15
  export * from './center.js';
15
16
  export * from './chat.js';
16
17
  export * from './check-in.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAElC,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAE5B,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAE9B,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAE/B,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAE9B,cAAc,WAAW,CAAC;AAE1B,cAAc,uBAAuB,CAAC;AACtC,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAE9B,cAAc,eAAe,CAAC;AAE9B,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAEhC,cAAc,sBAAsB,CAAC;AACrC,cAAc,yBAAyB,CAAC;AACxC,cAAc,wBAAwB,CAAC;AAEvC,cAAc,eAAe,CAAC;AAE9B,cAAc,mBAAmB,CAAC;AAClC,cAAc,iBAAiB,CAAC;AAEhC,cAAc,sBAAsB,CAAC;AAErC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AAEnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAE7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,mBAAmB,CAAC;AAClC,cAAc,WAAW,CAAC;AAC1B,cAAc,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAElC,cAAc,WAAW,CAAC;AAC1B,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAE5B,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAE9B,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAE/B,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAE9B,cAAc,WAAW,CAAC;AAE1B,cAAc,uBAAuB,CAAC;AACtC,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAE9B,cAAc,eAAe,CAAC;AAE9B,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAEhC,cAAc,sBAAsB,CAAC;AACrC,cAAc,yBAAyB,CAAC;AACxC,cAAc,wBAAwB,CAAC;AAEvC,cAAc,eAAe,CAAC;AAE9B,cAAc,mBAAmB,CAAC;AAClC,cAAc,iBAAiB,CAAC;AAEhC,cAAc,sBAAsB,CAAC;AAErC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AAEnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAE7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,mBAAmB,CAAC;AAClC,cAAc,WAAW,CAAC;AAC1B,cAAc,mBAAmB,CAAC"}
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ export * from './add-person-form.js';
13
13
  export * from './announcement.js';
14
14
  // ─── Location master data (the address picker's lookup API) ───────────────
15
15
  export * from './area.js';
16
+ export * from './body-metrics.js';
16
17
  export * from './center.js';
17
18
  // ─── Check-in QR (poster payload shared by owner, member and backend) ────────
18
19
  export * from './chat.js';