Skip to content

Commit 0e68695

Browse files
committed
feat: utils
1 parent bc5f09a commit 0e68695

File tree

10 files changed

+597
-0
lines changed

10 files changed

+597
-0
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { changeObjectKeysCaseRecursive } from '@app/web/utils/changeObjectKeysCaseRecursive'
2+
3+
describe('changeObjectKeysCaseRecursive', () => {
4+
it('should change object keys case to snake case recursively', () => {
5+
const input = {
6+
missing: null,
7+
other: undefined,
8+
number: 0,
9+
string: '',
10+
fooBar: 'fooBar',
11+
array: ['foo', 'bar'],
12+
arrayObject: [{ bizzBuzz: 'booBoo' }, { booBoo: 'booboo' }],
13+
subObject: {
14+
fooBar: 'fooBar',
15+
arrayObject: [{ bizzBuzz: 'booBoo' }, { booBoo: 'booboo' }],
16+
},
17+
}
18+
19+
expect(changeObjectKeysCaseRecursive(input, 'snake')).toEqual({
20+
missing: null,
21+
other: undefined,
22+
number: 0,
23+
string: '',
24+
foo_bar: 'fooBar',
25+
array: ['foo', 'bar'],
26+
array_object: [{ bizz_buzz: 'booBoo' }, { boo_boo: 'booboo' }],
27+
sub_object: {
28+
foo_bar: 'fooBar',
29+
array_object: [{ bizz_buzz: 'booBoo' }, { boo_boo: 'booboo' }],
30+
},
31+
})
32+
})
33+
})
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { camelCase, constantCase, pascalCase, snakeCase } from 'change-case'
2+
3+
type CaseType = 'snake' | 'camel' | 'constant' | 'pascal'
4+
5+
// exemple minimaliste de snake_case
6+
type SnakeCase<S extends string> =
7+
// on coupe la chaîne caractère par caractère
8+
S extends `${infer First}${infer Rest}`
9+
? // si le prochain caractère n'est pas en majuscule, on colle
10+
// sinon on insère un underscore
11+
Rest extends Uncapitalize<Rest>
12+
? `${Lowercase<First>}${SnakeCase<Rest>}`
13+
: `${Lowercase<First>}_${SnakeCase<Rest>}`
14+
: S
15+
16+
// camelCase depuis snake_case (vraiment simplifié)
17+
type CamelCase<S extends string> = S extends `${infer Before}_${infer After}`
18+
? `${Lowercase<Before>}${Capitalize<CamelCase<After>>}`
19+
: Lowercase<S>
20+
21+
// PASCALcase (simplifié)
22+
// (on part d'une base camelCase et on capitalise le premier)
23+
type PascalCase<S extends string> = S extends `${infer First}${infer Rest}`
24+
? `${Uppercase<First>}${Rest}`
25+
: S
26+
27+
// CONSTANT_CASE (tout majuscule, underscore)
28+
type ConstantCase<S extends string> = SnakeCase<S> extends infer R extends
29+
string
30+
? Uppercase<R>
31+
: never
32+
33+
// on choisit la transformation
34+
type ChangeCase<S extends string, C extends CaseType> = C extends 'snake'
35+
? SnakeCase<S>
36+
: C extends 'camel'
37+
? CamelCase<S>
38+
: C extends 'pascal'
39+
? PascalCase<CamelCase<S>> // on “nettoie” la chaîne avant
40+
: C extends 'constant'
41+
? ConstantCase<S>
42+
: S
43+
44+
// le type récursif sur les clés d’un objet
45+
export type ChangeObjectKeysCaseRecursive<
46+
T,
47+
C extends CaseType,
48+
> = T extends readonly any[] // si c’est un tableau, on applique récursivement à ses éléments
49+
? { [K in keyof T]: ChangeObjectKeysCaseRecursive<T[K], C> }
50+
: // si c’est un objet "pur" (pas un tableau), on renomme les clés
51+
T extends object
52+
? {
53+
[K in keyof T as ChangeCase<
54+
Extract<K, string>,
55+
C
56+
>]: ChangeObjectKeysCaseRecursive<T[K], C>
57+
}
58+
: T
59+
const changeCaseFunctions = {
60+
snake: snakeCase,
61+
camel: camelCase,
62+
constant: constantCase,
63+
pascal: pascalCase,
64+
} satisfies Record<CaseType, (input: string) => string>
65+
66+
export const changeObjectKeysCaseRecursive = <T, C extends CaseType>(
67+
data: T,
68+
caseType: C,
69+
): ChangeObjectKeysCaseRecursive<T, C> => {
70+
const changeCase = changeCaseFunctions[caseType]
71+
72+
if (Array.isArray(data)) {
73+
return data.map(
74+
(item) =>
75+
changeObjectKeysCaseRecursive(
76+
item,
77+
caseType,
78+
) as ChangeObjectKeysCaseRecursive<T, C>,
79+
) as ChangeObjectKeysCaseRecursive<T, C>
80+
}
81+
82+
if (data && typeof data === 'object' && data !== null) {
83+
return Object.fromEntries(
84+
Object.entries(data).map(([key, value]) => [
85+
changeCase(key),
86+
changeObjectKeysCaseRecursive(value, caseType),
87+
]),
88+
) as ChangeObjectKeysCaseRecursive<T, C>
89+
}
90+
91+
return data as ChangeObjectKeysCaseRecursive<T, C>
92+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export type CleanOperation = {
2+
name: string
3+
selector: RegExp
4+
field: string
5+
fix?: (toFix: string) => string
6+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { dureeAsString } from '@app/web/utils/dureeAsString'
2+
3+
describe('dureeAsString', () => {
4+
// use jest cases each
5+
6+
const cases = [
7+
{ value: 15, expected: '15 min' },
8+
{ value: 30.1, expected: '30 min' },
9+
{ value: 45, expected: '45 min' },
10+
{ value: 60, expected: '1 h' },
11+
{ value: 90, expected: '1 h 30' },
12+
{ value: 120, expected: '2 h' },
13+
{ value: 145.8, expected: '2 h 26' },
14+
{ value: 0, expected: '0 min' },
15+
]
16+
17+
test.each(cases)(
18+
'should format $value to $expected',
19+
({ value, expected }) => {
20+
expect(dureeAsString(value)).toEqual(expected)
21+
},
22+
)
23+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* The format is e.g "30 min" or "1 h 30"
3+
*/
4+
export const dureeAsString = (duree: number): string => {
5+
const hours = Math.floor(duree / 60)
6+
const minutes = Math.round(duree % 60)
7+
8+
if (hours === 0) {
9+
return `${minutes} min`
10+
}
11+
12+
if (minutes === 0) {
13+
return `${hours} h`
14+
}
15+
16+
return `${hours} h ${minutes}`
17+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export const createOpenStreetMapLink = ({
2+
coordinates,
3+
}: {
4+
coordinates: number[]
5+
}) =>
6+
`https://www.openstreetmap.org/?mlat=${coordinates[1]}&mlon=${coordinates[0]}#map=zoom-level/latitude/longitude`

apps/web/src/utils/fixTelephone.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { CleanOperation } from './clean-operation'
2+
3+
const fixDetailsInParenthesisInPhone = (field: string): CleanOperation => ({
4+
name: 'trailing details in phone',
5+
selector: /\s\(.*\)$/g,
6+
field,
7+
fix: (toFix: string): string => toFix.replace(/\s\(.*\)$/g, ''),
8+
})
9+
10+
const fixHeadingDetailsInPhone = (field: string): CleanOperation => ({
11+
name: 'heading details in phone',
12+
selector: /^\D{3,}/g,
13+
field,
14+
fix: (toFix: string): string => toFix.replace(/^\D{3,}/g, ''),
15+
})
16+
17+
const fixTrailingDetailsInPhone = (field: string): CleanOperation => ({
18+
name: 'trailing details in phone',
19+
selector: /\s[A-Za-z].*$/g,
20+
field,
21+
fix: (toFix: string): string => toFix.replace(/\s[A-Za-z].*$/g, ''),
22+
})
23+
24+
const fixWrongCharsInPhone = (field: string): CleanOperation => ({
25+
name: 'wrong chars in phone',
26+
selector: /(?!\w|\+)./g,
27+
field,
28+
fix: (toFix: string): string => toFix.replace(/(?!\w|\+)./g, ''),
29+
})
30+
31+
const fixUnexpectedPhoneList = (field: string): CleanOperation => ({
32+
name: 'unexpected phone list',
33+
selector: /\d{10}\/\/?\d{10}/,
34+
field,
35+
fix: (toFix: string): string => toFix.split('/')[0] ?? '',
36+
})
37+
38+
const fixShortCafPhone = (field: string): CleanOperation => ({
39+
name: 'short CAF phone',
40+
selector: /3230/,
41+
field,
42+
fix: (): string => '+33969322121',
43+
})
44+
45+
const fixShortAssuranceRetraitePhone = (field: string): CleanOperation => ({
46+
name: 'short assurance retraite phone',
47+
selector: /3960/,
48+
field,
49+
fix: (): string => '+33971103960',
50+
})
51+
52+
const fixMissingPlusCharAtStartingPhone = (field: string): CleanOperation => ({
53+
name: 'fix missing + at starting phone number',
54+
selector: /^(33|262|590|594|596)(\d+)/,
55+
field,
56+
fix: (toFix: string): string =>
57+
toFix.replace(/^(33|262|590|594|596)(\d+)/, '+$1$2'),
58+
})
59+
60+
const fixReplaceLeading0With33InPhoneNumberStatingWithPlus = (
61+
field: string,
62+
): CleanOperation => ({
63+
name: 'fix missing + at starting phone number',
64+
selector: /^\+0(\d{9})/,
65+
field,
66+
fix: (toFix: string): string => toFix.replace(/^\+0(\d{9})/, '+33$1'),
67+
})
68+
69+
const removeTooFewDigitsInPhone = (field: string): CleanOperation => ({
70+
name: 'too few digits in phone',
71+
selector: /^.{0,9}$/,
72+
field,
73+
})
74+
75+
const removeTooManyDigitsInPhone = (field: string): CleanOperation => ({
76+
name: 'too many digits in phone',
77+
selector: /^0.{10,}/,
78+
field,
79+
})
80+
81+
const removeOnly0ValueInPhone = (field: string): CleanOperation => ({
82+
name: 'fake number in phone',
83+
selector: /^0{10}$/,
84+
field,
85+
})
86+
87+
const removeNoValidNumbersInPhone = (field: string): CleanOperation => ({
88+
name: 'fake number in phone',
89+
selector: /^[1-9]\d{9}$/,
90+
field,
91+
})
92+
93+
const removeStartingByTwoZeroInPhone = (field: string): CleanOperation => ({
94+
name: 'fake number in phone',
95+
selector: /^00.+/,
96+
field,
97+
})
98+
99+
const keepFirstNumberIfMultiple = (field: string): CleanOperation => ({
100+
name: 'keep only the first phone number',
101+
selector: /\n/,
102+
field,
103+
fix: (toFix: string): string =>
104+
/^(?<phone>[^\n]+)/u.exec(toFix)?.groups?.phone ?? '',
105+
})
106+
107+
const cleanOperationIfAny = (
108+
cleanOperator: (colonne: string) => CleanOperation,
109+
telephone: string | null,
110+
): CleanOperation[] => (telephone == null ? [] : [cleanOperator(telephone)])
111+
112+
export const cleanTelephone = (telephone: string | null): CleanOperation[] => [
113+
...cleanOperationIfAny(removeStartingByTwoZeroInPhone, telephone),
114+
...cleanOperationIfAny(removeNoValidNumbersInPhone, telephone),
115+
...cleanOperationIfAny(fixUnexpectedPhoneList, telephone),
116+
...cleanOperationIfAny(fixDetailsInParenthesisInPhone, telephone),
117+
...cleanOperationIfAny(fixHeadingDetailsInPhone, telephone),
118+
...cleanOperationIfAny(fixTrailingDetailsInPhone, telephone),
119+
...cleanOperationIfAny(fixWrongCharsInPhone, telephone),
120+
...cleanOperationIfAny(fixShortCafPhone, telephone),
121+
...cleanOperationIfAny(fixShortAssuranceRetraitePhone, telephone),
122+
...cleanOperationIfAny(removeTooFewDigitsInPhone, telephone),
123+
...cleanOperationIfAny(removeTooManyDigitsInPhone, telephone),
124+
...cleanOperationIfAny(removeOnly0ValueInPhone, telephone),
125+
...cleanOperationIfAny(keepFirstNumberIfMultiple, telephone),
126+
...cleanOperationIfAny(fixMissingPlusCharAtStartingPhone, telephone),
127+
...cleanOperationIfAny(
128+
fixReplaceLeading0With33InPhoneNumberStatingWithPlus,
129+
telephone,
130+
),
131+
]
132+
133+
const canFixTelephone = (
134+
telephone: string | null,
135+
cleanOperation: CleanOperation,
136+
): telephone is string =>
137+
telephone != null && cleanOperation.selector.test(telephone)
138+
139+
const applyOperation =
140+
(cleanOperation: CleanOperation) => (telephone: string) =>
141+
cleanOperation.fix ? cleanOperation.fix(telephone) : null
142+
143+
const toFixedTelephone = (
144+
telephone: string | null,
145+
cleanOperation: CleanOperation,
146+
): string | null =>
147+
canFixTelephone(telephone, cleanOperation)
148+
? applyOperation(cleanOperation)(telephone)
149+
: telephone
150+
151+
const toInternationalFormat = (phone: string): string =>
152+
/^0\d{9}$/.test(phone) ? `+33${phone.slice(1)}` : phone
153+
154+
export const fixTelephone = (telephone: string | null) => {
155+
const fixed = cleanTelephone(telephone).reduce(toFixedTelephone, telephone)
156+
return fixed == null ? null : toInternationalFormat(fixed)
157+
}

0 commit comments

Comments
 (0)