Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/eslint/eslint.seatbelt.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@
"../../src/components/KYCWall/BaseKYCWall.tsx" "react-hooks/refs" 2
"../../src/components/LHNOptionsList/LHNEmptyState.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/LHNOptionsList/OptionRowLHN/OptionRow/Pressable.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/LocaleContextProvider.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/LocaleContextProvider.tsx" "react-hooks/set-state-in-effect" 1
"../../src/components/Lottie/index.tsx" "react-hooks/set-state-in-effect" 1
"../../src/components/MapView/GPSMapView.tsx" "no-restricted-imports" 1
Expand Down
4 changes: 2 additions & 2 deletions src/components/ApprovalWorkflowSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ function ApprovalWorkflowSection({
const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'Lightbulb', 'Pencil', 'Users', 'UserCheck']);
const styles = useThemeStyles();
const theme = useTheme();
const {translate, toLocaleOrdinal, localeCompare} = useLocalize();
const {translate, toLocaleOrdinalWithWords, localeCompare} = useLocalize();
const {convertToDisplayString} = useCurrencyListActions();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const approverTitle = (index: number) => {
Expand All @@ -79,7 +79,7 @@ function ApprovalWorkflowSection({
const fromProviderSuffix = hrProviderName ? ` (${translate('workflowsPage.approverFromProvider', {provider: hrProviderName})})` : '';
return `${translate('workflowsPage.manager')}${fromProviderSuffix}`;
}
return approvalWorkflow.approvers.length > 1 ? `${toLocaleOrdinal(index + 1, true)} ${translate('workflowsPage.approver').toLowerCase()}` : translate('workflowsPage.approver');
return approvalWorkflow.approvers.length > 1 ? `${toLocaleOrdinalWithWords(index + 1)} ${translate('workflowsPage.approver').toLowerCase()}` : translate('workflowsPage.approver');
};

const sortedMembers = approvalWorkflow.isDefault ? [] : sortAlphabetically(approvalWorkflow.members, 'displayName', localeCompare);
Expand Down
16 changes: 14 additions & 2 deletions src/components/LocaleContextProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ type LocaleContextProps = {
toLocaleDigit: (digit: string) => string;

/** Formats a number into its localized ordinal representation */
toLocaleOrdinal: (number: number, returnWords?: boolean) => string;
toLocaleOrdinal: (number: number) => string;

/** Formats a number into its localized ordinal representation with words */
toLocaleOrdinalWithWords: (number: number) => string;

/** Gets the standard digit corresponding to a locale digit */
fromLocaleDigit: (digit: string) => string;
Expand Down Expand Up @@ -84,6 +87,7 @@ const LocaleContext = createContext<LocaleContextProps>({
formatPhoneNumber: () => '',
toLocaleDigit: () => '',
toLocaleOrdinal: () => '',
toLocaleOrdinalWithWords: () => '',
fromLocaleDigit: () => '',
localeCompare: () => 0,
formatTravelDate: () => '',
Expand Down Expand Up @@ -155,7 +159,14 @@ function LocaleContextProvider({children}: LocaleContextProviderProps) {

const toLocaleDigit: LocaleContextProps['toLocaleDigit'] = (digit) => toLocaleDigitLocaleDigitUtils(currentLocale, digit);

const toLocaleOrdinal: LocaleContextProps['toLocaleOrdinal'] = (number, writtenOrdinals = false) => toLocaleOrdinalLocaleDigitUtils(currentLocale, number, writtenOrdinals);
const toLocaleOrdinal: LocaleContextProps['toLocaleOrdinal'] = (number) => toLocaleOrdinalLocaleDigitUtils(currentLocale, number);

const toLocaleOrdinalWithWords: LocaleContextProps['toLocaleOrdinalWithWords'] = (number) => {
if (number >= 1 && number <= 10) {
Comment thread
shubham1206agra marked this conversation as resolved.
return translate(`workflowsPage.frequencies.ordinals.${number}` as TranslationPaths);
}
return toLocaleOrdinalLocaleDigitUtils(currentLocale, number);
};

const fromLocaleDigit: LocaleContextProps['fromLocaleDigit'] = (localeDigit) => fromLocaleDigitLocaleDigitUtils(currentLocale, localeDigit);

Expand All @@ -180,6 +191,7 @@ function LocaleContextProvider({children}: LocaleContextProviderProps) {
formatPhoneNumber,
toLocaleDigit,
toLocaleOrdinal,
toLocaleOrdinalWithWords,
fromLocaleDigit,
localeCompare,
formatTravelDate,
Expand Down
18 changes: 7 additions & 11 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2749,20 +2749,16 @@ ${amount} für ${merchant} – ${date}`,
lastDayOfMonth: 'Letzter Tag des Monats',
lastBusinessDayOfMonth: 'Letzter Geschäftstag des Monats',
ordinals: {
one: 'st',
two: 'nd',
few: 'rd.',
other: 'Do.',
'1': 'Erste',
'2': 'Sekunde',
'1': 'Erster',
'2': 'Zweiter',
'3': 'Dritter',
'4': 'Vierte',
'5': 'Fünfte',
'6': 'Sechste',
'4': 'Vierter',
'5': 'Fünfter',
'6': 'Sechster',
'7': 'Siebter',
'8': 'Achter',
'9': 'Neuntel',
'10': 'Zehntel',
'9': 'Neunter',
'10': 'Zehnter',
},
},
approverInMultipleWorkflows: 'Dieses Mitglied gehört bereits zu einem anderen Genehmigungsworkflow. Alle Aktualisierungen hier werden sich auch dort auswirken.',
Expand Down
4 changes: 0 additions & 4 deletions src/languages/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2812,10 +2812,6 @@ ${amount} για ${merchant} - ${date}`,
lastDayOfMonth: 'Τελευταία ημέρα του μήνα',
lastBusinessDayOfMonth: 'Τελευταία εργάσιμη ημέρα του μήνα',
ordinals: {
one: 'στ',
two: '2η',
few: 'ημ.',
other: 'ημ.',
'1': 'Πρώτο',
'2': 'Δεύτερο',
'3': 'Τρίτο',
Expand Down
4 changes: 0 additions & 4 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2836,10 +2836,6 @@ const translations = {
lastDayOfMonth: 'Last day of the month',
lastBusinessDayOfMonth: 'Last business day of the month',
ordinals: {
one: 'st',
two: 'nd',
few: 'rd',
other: 'th',
/* eslint-disable @typescript-eslint/naming-convention */
'1': 'First',
'2': 'Second',
Expand Down
4 changes: 0 additions & 4 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2662,10 +2662,6 @@ ${amount} para ${merchant} - ${date}`,
lastDayOfMonth: 'Último día del mes',
lastBusinessDayOfMonth: 'Último día hábil del mes',
ordinals: {
one: 'º',
two: 'º',
few: 'º',
other: 'º',
'1': 'Primero',
'2': 'Segundo',
'3': 'Tercero',
Expand Down
6 changes: 1 addition & 5 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2754,12 +2754,8 @@ ${amount} pour ${merchant} - ${date}`,
lastDayOfMonth: 'Dernier jour du mois',
lastBusinessDayOfMonth: 'Dernier jour ouvrable du mois',
ordinals: {
one: 'er',
two: 'eᵉ',
few: 'ᵉ',
other: 'juil.',
'1': 'Premier',
'2': 'Seconde',
'2': 'Deuxième',
'3': 'Troisième',
'4': 'Quatrième',
'5': 'Cinquième',
Expand Down
4 changes: 0 additions & 4 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2739,10 +2739,6 @@ ${amount} per ${merchant} - ${date}`,
lastDayOfMonth: 'Ultimo giorno del mese',
lastBusinessDayOfMonth: 'Ultimo giorno lavorativo del mese',
ordinals: {
one: 'st',
two: 'nd',
few: 'rd',
other: 'gio',
'1': 'Primo',
'2': 'Secondo',
'3': 'Terzo',
Expand Down
6 changes: 1 addition & 5 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2714,12 +2714,8 @@ ${date} の ${merchant} への ${amount}`,
lastDayOfMonth: '月末最終日',
lastBusinessDayOfMonth: '月末最終営業日',
ordinals: {
one: 'st',
two: '番目',
few: 'rd',
other: '第',
'1': '最初',
'2': '',
'2': '2番目',
'3': '3 番目',
'4': '4番目',
'5': '5番目',
Expand Down
94 changes: 94 additions & 0 deletions src/languages/localeOrdinalMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Per-locale ordinal rendering, keyed by the CLDR plural category that
* `Intl.PluralRules(locale, {type: 'ordinal'})` selects for a given number.
*
* This lives outside the `src/languages/*.ts` files on purpose. Those are typed as
* `TranslationDeepObject<typeof en>`, which forces every locale to declare the exact same keys as
* English. Ordinals do not work that way: English needs four categories, Italian two, and eight of
* our locales need only `other`. Keeping the data here lets each locale declare exactly the
* categories its grammar can actually select.
*
* Each entry renders the complete string rather than just a suffix, because some locales form
* ordinals with a prefix (Japanese and Chinese use 第1, not 1第) which a suffix cannot express.
*/
import type {Locale} from '@src/CONST/LOCALES';
import {LOCALES} from '@src/CONST/LOCALES';

/** Renders the full ordinal for a number, e.g. `1st`, `1.`, `第1` */
type OrdinalRenderer = (count: number) => string;

/**
* `other` is required because it is the fallback for any category a locale does not declare, which
* keeps a missing entry from producing an empty string.
*/
type LocaleOrdinals = Partial<Record<Intl.LDMLPluralRule, OrdinalRenderer>> & {other: OrdinalRenderer};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB Minor type cleanup:

diff --git a/src/languages/localeOrdinalMap.ts b/src/languages/localeOrdinalMap.ts
index 4eecc665dd7..61477fff882 100644
--- a/src/languages/localeOrdinalMap.ts
+++ b/src/languages/localeOrdinalMap.ts
@@ -14,6 +14,8 @@
 import type {Locale} from '@src/CONST/LOCALES';
 import {LOCALES} from '@src/CONST/LOCALES';
 
+import type {SetRequired} from 'type-fest';
+
 /** Renders the full ordinal for a number, e.g. `1st`, `1.`, `第1` */
 type OrdinalRenderer = (count: number) => string;
 
@@ -21,7 +23,7 @@ type OrdinalRenderer = (count: number) => string;
  * `other` is required because it is the fallback for any category a locale does not declare, which
  * keeps a missing entry from producing an empty string.
  */
-type LocaleOrdinals = Partial<Record<Intl.LDMLPluralRule, OrdinalRenderer>> & {other: OrdinalRenderer};
+type LocaleOrdinals = SetRequired<Partial<Record<Intl.LDMLPluralRule, OrdinalRenderer>>, 'other'>;
 
 const localeOrdinalMap: Record<Locale, LocaleOrdinals> = {
     /** Categories: one, two, few, other. The only locale here needing all four. */


const localeOrdinalMap: Record<Locale, LocaleOrdinals> = {
/** Categories: one, two, few, other. The only locale here needing all four. */
[LOCALES.EN]: {
one: (count) => `${count}st`,
two: (count) => `${count}nd`,
few: (count) => `${count}rd`,
other: (count) => `${count}th`,
},

/** Categories: other. Uses the masculine ordinal indicator. */
[LOCALES.ES]: {
other: (count) => `${count}º`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB follow-up: figure out how to correctly use the feminine ordinal indicator when appropriate.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MelvinBot Create a follow-up issue to correctly handle gendered nouns in Spanish, Greek and any other locales that use them (for adjectives, ordinality, and any other grammatical structures you can think of that would be effected by this).

Label it Weekly, assign to @shubham1206agra and myself

},

/** Categories: one, other. 1 is written `1er`, everything else takes `e`. */
[LOCALES.FR]: {
one: (count) => `${count}er`,
other: (count) => `${count}e`,
},

/** Categories: other. German ordinals are the number followed by a period. */
[LOCALES.DE]: {
other: (count) => `${count}.`,
},

/**
* Categories: other.
*
* Greek ordinals inflect for gender. `η` is the feminine form, which agrees with the nouns this
* is currently used for. A different gender would need a different entry, so this is not safe to
* reuse for arbitrary nouns without revisiting.
*/
[LOCALES.EL]: {
other: (count) => `${count}η`,
},

/** Categories: many, other. Uses the masculine ordinal indicator. */
[LOCALES.IT]: {
many: (count) => `${count}º`,
other: (count) => `${count}º`,
},

/** Categories: other. Japanese forms ordinals with a prefix. */
[LOCALES.JA]: {
other: (count) => `第${count}`,
},

/** Categories: other. */
[LOCALES.NL]: {
other: (count) => `${count}e`,
},

/** Categories: other. Polish ordinals are the number followed by a period. */
[LOCALES.PL]: {
other: (count) => `${count}.`,
},

/** Categories: other. */
[LOCALES.PT_BR]: {
other: (count) => `${count}º`,
},

/** Categories: other. Chinese forms ordinals with a prefix. */
[LOCALES.ZH_HANS]: {
other: (count) => `第${count}`,
},
};

export default localeOrdinalMap;
4 changes: 0 additions & 4 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2740,10 +2740,6 @@ ${amount} voor ${merchant} - ${date}`,
lastDayOfMonth: 'Laatste dag van de maand',
lastBusinessDayOfMonth: 'Laatste werkdag van de maand',
ordinals: {
one: 'ste',
two: 'nd',
few: 'rd',
other: 'doe',
'1': 'Eerste',
'2': 'Tweede',
'3': 'Derde',
Expand Down
24 changes: 10 additions & 14 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2730,20 +2730,16 @@ ${amount} dla ${merchant} - ${date}`,
lastDayOfMonth: 'Ostatni dzień miesiąca',
lastBusinessDayOfMonth: 'Ostatni dzień roboczy miesiąca',
ordinals: {
one: 'śr',
two: 'nd',
few: 'rd',
other: 'czw.',
'1': 'Pierwszy',
'2': 'Sekunda',
'3': 'Trzeci',
'4': 'Czwarty',
'5': 'Piąty',
'6': 'Szósty',
'7': 'Siódmy',
'8': 'Ósmy',
'9': 'Dziewiąty',
'10': 'Dziesiąty',
'1': 'Pierwsza',
'2': 'Druga',
'3': 'Trzecia',
'4': 'Czwarta',
'5': 'Piąta',
'6': 'Szósta',
'7': 'Siódma',
'8': 'Ósma',
'9': 'Dziewiąta',
'10': 'Dziesiąta',
},
},
approverInMultipleWorkflows: 'Ten członek należy już do innego procesu zatwierdzania. Wszelkie zmiany wprowadzone tutaj będą widoczne także tam.',
Expand Down
4 changes: 0 additions & 4 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2731,10 +2731,6 @@ ${amount} para ${merchant} - ${date}`,
lastDayOfMonth: 'Último dia do mês',
lastBusinessDayOfMonth: 'Último dia útil do mês',
ordinals: {
one: 'º',
two: 'nd',
few: 'rd',
other: 'º',
'1': 'Primeiro',
'2': 'Segundo',
'3': 'Terceiro',
Expand Down
6 changes: 1 addition & 5 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2645,12 +2645,8 @@ ${amount},商户:${merchant} - 日期:${date}`,
lastDayOfMonth: '月末最后一天',
lastBusinessDayOfMonth: '每月的最后一个工作日',
ordinals: {
one: '第第',
two: '第几位',
few: '第 rd',
other: '第',
'1': '第一',
'2': '',
'2': '第二',
'3': '第三',
'4': '第四',
'5': '第五',
Expand Down
49 changes: 22 additions & 27 deletions src/libs/LocaleDigitUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type {TranslationPaths} from '@src/languages/types';
import localeOrdinalMap from '@src/languages/localeOrdinalMap';
import type Locale from '@src/types/onyx/Locale';

import {translate} from './Localize';
import memoize from './memoize';
import {format, formatToParts} from './NumberFormatUtils';

Expand Down Expand Up @@ -69,37 +68,33 @@ function fromLocaleDigit(locale: Locale | undefined, localeDigit: string): strin
return STANDARD_DIGITS.at(index) ?? '';
}

// Constructing Intl.PluralRules is expensive, and this runs once per row in lists,
// so the instance is cached per locale.
const createOrdinalPluralRules = (locale: Locale): Intl.PluralRules => new Intl.PluralRules(locale, {type: 'ordinal'});
const memoizedCreateOrdinalPluralRules = memoize(createOrdinalPluralRules);

/**
* Formats a number into its localized ordinal representation i.e 1st, 2nd etc
* @param locale - The locale to use for formatting
* Formats a number into its localized ordinal representation, e.g. `1st` in English, `1.` in German
* or `第1` in Japanese.
*
* @param locale - The locale to use for formatting. Returns an empty string when absent, matching the
* placeholder on the locale context.
* @param number - The number to format
* @param writtenOrdinals - If true, returns the written ordinal (e.g. "first", "second") for numbers 1-10
*/
function toLocaleOrdinal(locale: Locale | undefined, number: number, writtenOrdinals = false): string {
// Defaults to "other" suffix or "th" in English
let suffixKey: TranslationPaths = 'workflowsPage.frequencies.ordinals.other';

// Calculate last digit of the number to determine basic ordinality
const lastDigit = number % 10;

// Calculate last two digits to handle exceptions in the 11-13 range
const lastTwoDigits = number % 100;

if (writtenOrdinals && number >= 1 && number <= 10) {
return translate(locale, `workflowsPage.frequencies.ordinals.${number}` as TranslationPaths);
}

if (lastDigit === 1 && lastTwoDigits !== 11) {
suffixKey = 'workflowsPage.frequencies.ordinals.one';
} else if (lastDigit === 2 && lastTwoDigits !== 12) {
suffixKey = 'workflowsPage.frequencies.ordinals.two';
} else if (lastDigit === 3 && lastTwoDigits !== 13) {
suffixKey = 'workflowsPage.frequencies.ordinals.few';
function toLocaleOrdinal(locale: Locale | undefined, number: number): string {
if (!locale) {
return '';
}

const suffix = translate(locale, suffixKey);
// Ordinal rules vary far more than they appear to: English selects four categories, Italian two,
// and most locales only ever select `other`. Asking Intl avoids reimplementing English's rule and
// applying it everywhere.
const pluralRule = memoizedCreateOrdinalPluralRules(locale).select(number);
const localeOrdinalRules = localeOrdinalMap[locale];

return `${number}${suffix}`;
// Each locale declares only the categories it needs, so the selected one may be missing.
const rule = localeOrdinalRules[pluralRule] ?? localeOrdinalRules.other;
return rule(number);
}

export {toLocaleDigit, toLocaleOrdinal, fromLocaleDigit};
Loading
Loading