From 5217835c5587efe019c7eb308ec03853dd443021 Mon Sep 17 00:00:00 2001 From: Jed Fox Date: Thu, 8 Sep 2022 09:37:45 -0400 Subject: [PATCH] Implement localization for schedule descriptions (#225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * monthUtils.{format → nonLocalizedFormat} * Implement localization for schedule descriptions * Remove outdated comment * Add general.ordinal in Spanish Co-Authored-By: Manuel Eduardo Cánepa Cihuelo <10290593+manuelcanepa@users.noreply.github.com> * yay time zones? * fix: re-add missing keys * fix: fix broken i18n imports/initialisation * style: linting * fix: re-add english ordinal keys * fix: add remaining english ordinal keys * fix: correct dates in schedules.js * refactor: store translations keys for loot-core in loot-core * fix: add ns to i18n.t calls directly so parser can find them * feat: add spanish translation from manuelcanepa * fix: add comments to help i18n-parser to find contexts * fix: add "many" context to spanish translations Co-authored-by: Manuel Eduardo Cánepa Cihuelo <10290593+manuelcanepa@users.noreply.github.com> Co-authored-by: Tom French --- .github/workflows/i18n.yml | 2 +- .../desktop-client/i18next-parser.config.js | 1 + .../src/components/budget/index.js | 2 +- .../src/components/modals/EditRule.js | 2 +- .../src/components/modals/ManageRules.js | 4 +- .../src/components/reports/CashFlow.js | 2 +- .../src/components/reports/NetWorth.js | 2 +- .../components/schedules/DiscoverSchedules.js | 4 +- .../src/components/schedules/EditSchedule.js | 4 +- .../components/schedules/SchedulesTable.js | 2 +- .../src/components/tutorial/Overspending.js | 2 +- packages/desktop-client/src/locales/index.js | 10 +- packages/loot-core/i18next-parser.config.js | 16 ++ packages/loot-core/package.json | 5 +- packages/loot-core/src/locales/en-GB.json | 27 ++ packages/loot-core/src/locales/es-ES.json | 28 +++ packages/loot-core/src/shared/months.js | 6 +- packages/loot-core/src/shared/schedules.js | 155 ++++++++---- .../loot-core/src/shared/schedules.test.js | 231 +++++++++++------- .../src/components/RecurringSchedulePicker.js | 12 +- .../src/components/budget/index.js | 6 +- .../components/budget/report/BudgetSummary.js | 2 +- .../budget/rollover/BudgetSummary.js | 7 +- .../src/components/mobile/budget.js | 2 +- .../src/components/mobile/transaction.js | 2 +- .../components/modals/ImportTransactions.js | 2 +- .../mobile/src/components/budget/index.js | 5 +- yarn.lock | 2 + 28 files changed, 376 insertions(+), 169 deletions(-) create mode 100644 packages/loot-core/i18next-parser.config.js create mode 100644 packages/loot-core/src/locales/en-GB.json create mode 100644 packages/loot-core/src/locales/es-ES.json diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index 4bb40d7..677c886 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -15,4 +15,4 @@ jobs: - name: Set up environment uses: ./.github/actions/setup - name: Check i18n keys - run: yarn workspace @actual-app/web check-i18n --fail-on-update + run: yarn workspaces foreach --verbose run check-i18n --fail-on-update diff --git a/packages/desktop-client/i18next-parser.config.js b/packages/desktop-client/i18next-parser.config.js index 90bd427..3948f86 100644 --- a/packages/desktop-client/i18next-parser.config.js +++ b/packages/desktop-client/i18next-parser.config.js @@ -2,6 +2,7 @@ module.exports = { input: ['src/**/*.js'], output: 'src/locales/$LOCALE.json', locales: ['en-GB', 'es-ES'], + defaultNamespace: 'web', sort: true, // Force usage of JsxLexer for .js files as otherwise we can't pick up components. lexers: { diff --git a/packages/desktop-client/src/components/budget/index.js b/packages/desktop-client/src/components/budget/index.js index 5a73395..378ad60 100644 --- a/packages/desktop-client/src/components/budget/index.js +++ b/packages/desktop-client/src/components/budget/index.js @@ -327,7 +327,7 @@ class Budget extends React.PureComponent { pathname: '/accounts', state: { goBack: true, - filterName: `${categoryName} (${monthUtils.format( + filterName: `${categoryName} (${monthUtils.nonLocalizedFormat( month, 'MMMM yyyy' )})`, diff --git a/packages/desktop-client/src/components/modals/EditRule.js b/packages/desktop-client/src/components/modals/EditRule.js index 5e55cee..7b930bb 100644 --- a/packages/desktop-client/src/components/modals/EditRule.js +++ b/packages/desktop-client/src/components/modals/EditRule.js @@ -259,7 +259,7 @@ function ScheduleDescription({ id }) { - Next: {monthUtils.format(schedule.next_date, dateFormat)} + Next: {monthUtils.nonLocalizedFormat(schedule.next_date, dateFormat)} diff --git a/packages/desktop-client/src/components/modals/ManageRules.js b/packages/desktop-client/src/components/modals/ManageRules.js index 7a31188..2784ece 100644 --- a/packages/desktop-client/src/components/modals/ManageRules.js +++ b/packages/desktop-client/src/components/modals/ManageRules.js @@ -1,4 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { format as formatDate, parseISO } from 'date-fns'; @@ -52,6 +53,7 @@ export function Value({ data: dataProp, describe = x => x.name }) { + const { i18n } = useTranslation(); let { data, dateFormat } = useSelector(state => { let data; if (dataProp) { @@ -95,7 +97,7 @@ export function Value({ } else if (field === 'date') { if (value) { if (value.frequency) { - return getRecurringDescription(value); + return getRecurringDescription(value, i18n); } return formatDate(parseISO(value), dateFormat); } diff --git a/packages/desktop-client/src/components/reports/CashFlow.js b/packages/desktop-client/src/components/reports/CashFlow.js index ec99a1a..f7a20fc 100644 --- a/packages/desktop-client/src/components/reports/CashFlow.js +++ b/packages/desktop-client/src/components/reports/CashFlow.js @@ -53,7 +53,7 @@ function CashFlow() { .rangeInclusive(earliestMonth, monthUtils.currentMonth()) .map(month => ({ name: month, - pretty: monthUtils.format(month, 'MMMM, yyyy') + pretty: monthUtils.nonLocalizedFormat(month, 'MMMM, yyyy') })) .reverse(); diff --git a/packages/desktop-client/src/components/reports/NetWorth.js b/packages/desktop-client/src/components/reports/NetWorth.js index 9a17a1e..5a9fc2c 100644 --- a/packages/desktop-client/src/components/reports/NetWorth.js +++ b/packages/desktop-client/src/components/reports/NetWorth.js @@ -51,7 +51,7 @@ function NetWorth({ accounts }) { .rangeInclusive(earliestMonth, monthUtils.currentMonth()) .map(month => ({ name: month, - pretty: monthUtils.format(month, 'MMMM, yyyy') + pretty: monthUtils.nonLocalizedFormat(month, 'MMMM, yyyy') })) .reverse(); diff --git a/packages/desktop-client/src/components/schedules/DiscoverSchedules.js b/packages/desktop-client/src/components/schedules/DiscoverSchedules.js index d769547..76335d7 100644 --- a/packages/desktop-client/src/components/schedules/DiscoverSchedules.js +++ b/packages/desktop-client/src/components/schedules/DiscoverSchedules.js @@ -36,12 +36,12 @@ let ROW_HEIGHT = 43; function DiscoverSchedulesTable({ schedules, loading }) { let selectedItems = useSelectedItems(); let dispatchSelected = useSelectedDispatch(); - const { t } = useTranslation(); + let { t, i18n } = useTranslation(); function renderItem({ item }) { let selected = selectedItems.has(item.id); let amountOp = item._conditions.find(c => c.field === 'amount').op; - let recurDescription = getRecurringDescription(item.date); + let recurDescription = getRecurringDescription(item.date, i18n); return ( {state.upcomingDates.map(date => ( - {monthUtils.format(date, `${dateFormat} EEEE`)} + + {monthUtils.nonLocalizedFormat(date, `${dateFormat} EEEE`)} + ))} diff --git a/packages/desktop-client/src/components/schedules/SchedulesTable.js b/packages/desktop-client/src/components/schedules/SchedulesTable.js index 9877582..0f357d2 100644 --- a/packages/desktop-client/src/components/schedules/SchedulesTable.js +++ b/packages/desktop-client/src/components/schedules/SchedulesTable.js @@ -181,7 +181,7 @@ export function SchedulesTable({ {item.next_date - ? monthUtils.format(item.next_date, dateFormat) + ? monthUtils.nonLocalizedFormat(item.next_date, dateFormat) : null} diff --git a/packages/desktop-client/src/components/tutorial/Overspending.js b/packages/desktop-client/src/components/tutorial/Overspending.js index fd4881c..7a9a7fc 100644 --- a/packages/desktop-client/src/components/tutorial/Overspending.js +++ b/packages/desktop-client/src/components/tutorial/Overspending.js @@ -16,7 +16,7 @@ import Navigation from './Navigation'; function Overspending({ navigationProps, stepTwo }) { let currentMonth = monthUtils.currentMonth(); let sheetName = monthUtils.sheetForMonth(currentMonth); - let month = monthUtils.format(currentMonth, 'MMM'); + let month = monthUtils.nonLocalizedFormat(currentMonth, 'MMM'); let [minimized, toggle] = useMinimized(); return ( diff --git a/packages/desktop-client/src/locales/index.js b/packages/desktop-client/src/locales/index.js index 5c0fea7..89b827a 100644 --- a/packages/desktop-client/src/locales/index.js +++ b/packages/desktop-client/src/locales/index.js @@ -2,15 +2,20 @@ import { initReactI18next } from 'react-i18next'; import i18n from 'i18next'; +import enUKCore from 'loot-core/src/locales/en-GB.json'; +import esESCore from 'loot-core/src/locales/es-ES.json'; + import enUK from './en-GB.json'; import esES from './es-ES.json'; const resources = { en: { - translation: enUK + web: enUK, + core: enUKCore }, es: { - translation: esES + web: esES, + core: esESCore } }; @@ -18,6 +23,7 @@ i18n .use(initReactI18next) // passes i18n down to react-i18next .init({ resources, + defaultNS: 'web', lng: 'es', // language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources // you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage // if you're using a language detector, do not define the lng option diff --git a/packages/loot-core/i18next-parser.config.js b/packages/loot-core/i18next-parser.config.js new file mode 100644 index 0000000..dde02d3 --- /dev/null +++ b/packages/loot-core/i18next-parser.config.js @@ -0,0 +1,16 @@ +module.exports = { + input: ['src/**/*.js'], + output: 'src/locales/$LOCALE.json', + locales: ['en-GB', 'es-ES'], + defaultNamespace: 'core', + sort: true, + // Force usage of JsxLexer for .js files as otherwise we can't pick up components. + lexers: { + js: ['JsxLexer'], + ts: ['JsxLexer'], + jsx: ['JsxLexer'], + tsx: ['JsxLexer'], + + default: ['JsxLexer'] + } +}; diff --git a/packages/loot-core/package.json b/packages/loot-core/package.json index 72dbd19..a34a508 100644 --- a/packages/loot-core/package.json +++ b/packages/loot-core/package.json @@ -12,7 +12,8 @@ "lint": "eslint src", "test": "npm-run-all -cp 'test:*'", "test:node": "jest -c jest.config.js", - "test:web": "jest -c jest.web.config.js" + "test:web": "jest -c jest.web.config.js", + "check-i18n": "i18next" }, "author": "", "license": "ISC", @@ -56,6 +57,8 @@ "fake-indexeddb": "^3.1.3", "fast-check": "2.13.0", "fast-glob": "^2.2.0", + "i18next": "^21.9.1", + "i18next-parser": "^6.5.0", "jest": "^28.1.0", "jsverify": "^0.8.4", "lru-cache": "^5.1.1", diff --git a/packages/loot-core/src/locales/en-GB.json b/packages/loot-core/src/locales/en-GB.json new file mode 100644 index 0000000..90284ef --- /dev/null +++ b/packages/loot-core/src/locales/en-GB.json @@ -0,0 +1,27 @@ +{ + "general": { + "ordinal_one": "{{count}}st", + "ordinal_two": "{{count}}nd", + "ordinal_few": "{{count}}rd", + "ordinal_other": "{{count}}th" + }, + "schedules": { + "recurring": { + "monthly_one": "Every month on the {{day}}", + "monthly_other": "Every {{count}} months on the {{day}}", + "monthlyPattern_one": "Every month on the {{pattern}}", + "monthlyPattern_other": "Every {{count}} months on the {{pattern}}", + "pattern": { + "lastDay": "last day", + "lastWeekday": "last {{dayName}}", + "lastWeekday_sameDay": "last", + "weekAndDay": "{{week}} {{dayName}}", + "weekAndDay_sameDay": "{{week}}" + }, + "weekly_one": "Every week on {{day}}", + "weekly_other": "Every {{count}} weeks on {{day}}", + "yearly_one": "Every year on {{day}}", + "yearly_other": "Every {{count}} years on {{day}}" + } + } +} diff --git a/packages/loot-core/src/locales/es-ES.json b/packages/loot-core/src/locales/es-ES.json new file mode 100644 index 0000000..fd7db37 --- /dev/null +++ b/packages/loot-core/src/locales/es-ES.json @@ -0,0 +1,28 @@ +{ + "general": { + "ordinal_other": "{{count}}º" + }, + "schedules": { + "recurring": { + "monthly_one": "Cada {{day}} día del mes", + "monthly_many": "Cada {{count}} de meses el {{day}} día", + "monthly_other": "Cada {{count}} meses el {{day}} día", + "monthlyPattern_one": "Cada mes {{pattern}}", + "monthlyPattern_many": "Cada {{count}} de meses {{pattern}}", + "monthlyPattern_other": "Cada {{count}} meses {{pattern}}", + "pattern": { + "lastDay": "el último día", + "lastWeekday": "el último {{dayName}}", + "lastWeekday_sameDay": "el último", + "weekAndDay": "{{week}} {{dayName}}", + "weekAndDay_sameDay": "{{week}}" + }, + "weekly_one": "Cada semana {{day}}", + "weekly_many": "Cada {{count}} de semanas el {{day}}", + "weekly_other": "Cada {{count}} semanas el {{day}}", + "yearly_one": "Cada año el {{day}}", + "yearly_many": "Cada {{count}} de años el {{day}}", + "yearly_other": "Cada {{count}} años el {{day}}" + } + } +} diff --git a/packages/loot-core/src/shared/months.js b/packages/loot-core/src/shared/months.js index 70e81a9..a0bec79 100644 --- a/packages/loot-core/src/shared/months.js +++ b/packages/loot-core/src/shared/months.js @@ -210,11 +210,11 @@ export function sheetForMonth(month) { return 'budget' + month.replace('-', ''); } -export function nameForMonth(month) { - return d.format(_parse(month), "MMMM 'yy"); +export function format(month, opts, locale) { + return Intl.DateTimeFormat(locale, opts).format(_parse(month)); } -export function format(month, str) { +export function nonLocalizedFormat(month, str) { return d.format(_parse(month), str); } diff --git a/packages/loot-core/src/shared/schedules.js b/packages/loot-core/src/shared/schedules.js index af92e06..9a1e915 100644 --- a/packages/loot-core/src/shared/schedules.js +++ b/packages/loot-core/src/shared/schedules.js @@ -42,39 +42,49 @@ export function getHasTransactionsQuery(schedules) { .select(['schedule', 'date']); } -function makeNumberSuffix(num) { - // Slight abuse of date-fns to turn a number like "1" into the full - // form "1st" but formatting a date with that number - return monthUtils.format(new Date(2020, 0, num, 12), 'do'); -} - -function prettyDayName(day) { +function prettyDayName(day, locale) { let days = { - SU: 'Sunday', - MO: 'Monday', - TU: 'Tuesday', - WE: 'Wednesday', - TH: 'Thursday', - FR: 'Friday', - SA: 'Saturday' + SU: new Date('2020-01-05T12:00:00.000Z'), + MO: new Date('2020-01-06T12:00:00.000Z'), + TU: new Date('2020-01-07T12:00:00.000Z'), + WE: new Date('2020-01-08T12:00:00.000Z'), + TH: new Date('2020-01-09T12:00:00.000Z'), + FR: new Date('2020-01-10T12:00:00.000Z'), + SA: new Date('2020-01-11T12:00:00.000Z') }; - return days[day]; + return Intl.DateTimeFormat(locale, { weekday: 'long' }).format(days[day]); } -export function getRecurringDescription(config) { +function formatMonthAndDay(month, i18n) { + let parts = Intl.DateTimeFormat(i18n.resolvedLanguage, { + month: 'short', + day: 'numeric' + }).formatToParts(monthUtils.parseDate(month)); + let dayPart = parts.find(p => p.type === 'day'); + dayPart.value = i18n.t('general.ordinal', { + count: monthUtils.parseDate(month).getDate(), + ordinal: true, + ns: 'core' + }); + return parts.map(part => part.value).join(''); +} + +export function getRecurringDescription(config, i18n) { let interval = config.interval || 1; switch (config.frequency) { case 'weekly': { - let desc = 'Every '; - desc += interval !== 1 ? `${interval} weeks` : 'week'; - desc += ' on ' + monthUtils.format(config.start, 'EEEE'); - return desc; + return i18n.t('schedules.recurring.weekly', { + count: interval, + day: monthUtils.format( + config.start, + { weekday: 'long' }, + i18n.resolvedLanguage + ), + ns: 'core' + }); } case 'monthly': { - let desc = 'Every '; - desc += interval !== 1 ? `${interval} months` : 'month'; - if (config.patterns && config.patterns.length > 0) { // Sort the days ascending. We filter out -1 because that // represents "last days" and should always be last, but this @@ -95,56 +105,101 @@ export function getRecurringDescription(config) { // Add on all -1 values to the end patterns = patterns.concat(config.patterns.filter(p => p.value === -1)); - desc += ' on the '; - let strs = []; let uniqueDays = new Set(patterns.map(p => p.type)); - let isSameDay = uniqueDays.length === 1 && !uniqueDays.has('day'); + let context = + uniqueDays.length === 1 && !uniqueDays.has('day') + ? 'sameDay' + : undefined; for (let pattern of patterns) { if (pattern.type === 'day') { if (pattern.value === -1) { - strs.push('last day'); + strs.push( + i18n.t('schedules.recurring.pattern.lastDay', { + ns: 'core' + }) + ); } else { - // Example: 15th day - strs.push(makeNumberSuffix(pattern.value)); + strs.push( + i18n.t('general.ordinal', { + count: pattern.value, + ordinal: true, + ns: 'core' + }) + ); } } else { - let dayName = isSameDay ? '' : ' ' + prettyDayName(pattern.type); + let dayName = prettyDayName( + pattern.type, + i18n.resolvedLanguage, + i18n.resolvedLanguage + ); if (pattern.value === -1) { // Example: last Monday - strs.push('last' + dayName); + // t('schedules.recurring.pattern.lastWeekday') + // t('schedules.recurring.pattern.lastWeekday_sameDay') + strs.push( + i18n.t('schedules.recurring.pattern.lastWeekday', { + context, + dayName, + ns: 'core' + }) + ); } else { // Example: 3rd Monday - strs.push(makeNumberSuffix(pattern.value) + dayName); + // t('schedules.recurring.pattern.weekAndDay') + // t('schedules.recurring.pattern.weekAndDay_sameDay') + strs.push( + i18n.t('schedules.recurring.pattern.weekAndDay', { + context, + week: i18n.t('general.ordinal', { + count: pattern.value, + ordinal: true, + ns: 'core' + }), + dayName, + ns: 'core' + }) + ); } } } - if (strs.length > 2) { - desc += strs.slice(0, strs.length - 1).join(', '); - desc += ', and '; - desc += strs[strs.length - 1]; - } else { - desc += strs.join(' and '); - } - - if (isSameDay) { - desc += ' ' + prettyDayName(patterns[0].type); - } + return i18n.t('schedules.recurring.monthlyPattern', { + context, + count: interval, + day: prettyDayName(patterns[0].type, i18n.resolvedLanguage), + pattern: new Intl.ListFormat(i18n.resolvedLanguage, { + style: 'long', + type: 'conjunction' + }).format(strs), + ns: 'core' + }); } else { - desc += ' on the ' + monthUtils.format(config.start, 'do'); + return i18n.t('schedules.recurring.monthly', { + count: interval, + day: i18n.t('general.ordinal', { + count: monthUtils.parseDate(config.start).getDate(), + ordinal: true, + ns: 'core' + }), + ns: 'core' + }); } - - return desc; } case 'yearly': { - let desc = 'Every '; - desc += interval !== 1 ? `${interval} years` : 'year'; - desc += ' on ' + monthUtils.format(config.start, 'LLL do'); - return desc; + return i18n.t( + 'schedules.recurring.yearly', + { + count: interval, + day: formatMonthAndDay(config.start, i18n), + ns: 'core' + }, + i18n.resolvedLanguage + ); } default: return 'Recurring error'; diff --git a/packages/loot-core/src/shared/schedules.test.js b/packages/loot-core/src/shared/schedules.test.js index a3733b4..15cc5fe 100644 --- a/packages/loot-core/src/shared/schedules.test.js +++ b/packages/loot-core/src/shared/schedules.test.js @@ -1,7 +1,18 @@ +import i18n from 'i18next'; import MockDate from 'mockdate'; +import enUKCore from '../locales/en-GB.json'; import { getRecurringDescription } from './schedules'; +i18n.init({ + lng: 'en', + resources: { + en: { + core: enUKCore + } + } +}); + describe('recurring date description', () => { beforeEach(() => { MockDate.set(new Date(2021, 4, 14)); @@ -9,149 +20,197 @@ describe('recurring date description', () => { it('describes weekly interval', () => { expect( - getRecurringDescription({ start: '2021-05-17', frequency: 'weekly' }) + getRecurringDescription( + { start: '2021-05-17', frequency: 'weekly' }, + i18n + ) ).toBe('Every week on Monday'); expect( - getRecurringDescription({ - start: '2021-05-17', - frequency: 'weekly', - interval: 2 - }) + getRecurringDescription( + { + start: '2021-05-17', + frequency: 'weekly', + interval: 2 + }, + i18n + ) ).toBe('Every 2 weeks on Monday'); }); it('describes monthly interval', () => { expect( - getRecurringDescription({ start: '2021-04-25', frequency: 'monthly' }) + getRecurringDescription( + { start: '2021-04-25', frequency: 'monthly' }, + i18n + ) ).toBe('Every month on the 25th'); expect( - getRecurringDescription({ - start: '2021-04-25', - frequency: 'monthly', - interval: 2 - }) + getRecurringDescription( + { + start: '2021-04-25', + frequency: 'monthly', + interval: 2 + }, + i18n + ) ).toBe('Every 2 months on the 25th'); expect( - getRecurringDescription({ - start: '2021-04-25', - frequency: 'monthly', - patterns: [{ type: 'day', value: 25 }] - }) + getRecurringDescription( + { + start: '2021-04-25', + frequency: 'monthly', + patterns: [{ type: 'day', value: 25 }] + }, + i18n + ) ).toBe('Every month on the 25th'); expect( - getRecurringDescription({ - start: '2021-04-25', - frequency: 'monthly', - interval: 2, - patterns: [{ type: 'day', value: 25 }] - }) + getRecurringDescription( + { + start: '2021-04-25', + frequency: 'monthly', + interval: 2, + patterns: [{ type: 'day', value: 25 }] + }, + i18n + ) ).toBe('Every 2 months on the 25th'); // Last day should work expect( - getRecurringDescription({ - start: '2021-04-25', - frequency: 'monthly', - patterns: [{ type: 'day', value: 31 }] - }) + getRecurringDescription( + { + start: '2021-04-25', + frequency: 'monthly', + patterns: [{ type: 'day', value: 31 }] + }, + i18n + ) ).toBe('Every month on the 31st'); // -1 should work, representing the last day expect( - getRecurringDescription({ - start: '2021-04-25', - frequency: 'monthly', - patterns: [{ type: 'day', value: -1 }] - }) + getRecurringDescription( + { + start: '2021-04-25', + frequency: 'monthly', + patterns: [{ type: 'day', value: -1 }] + }, + i18n + ) ).toBe('Every month on the last day'); // Day names should work expect( - getRecurringDescription({ - start: '2021-04-25', - frequency: 'monthly', - patterns: [{ type: 'FR', value: 2 }] - }) + getRecurringDescription( + { + start: '2021-04-25', + frequency: 'monthly', + patterns: [{ type: 'FR', value: 2 }] + }, + i18n + ) ).toBe('Every month on the 2nd Friday'); expect( - getRecurringDescription({ - start: '2021-04-25', - frequency: 'monthly', - patterns: [{ type: 'FR', value: -1 }] - }) + getRecurringDescription( + { + start: '2021-04-25', + frequency: 'monthly', + patterns: [{ type: 'FR', value: -1 }] + }, + i18n + ) ).toBe('Every month on the last Friday'); }); it('describes monthly interval with multiple days', () => { // Note how order doesn't matter - the day should be sorted expect( - getRecurringDescription({ - start: '2021-04-25', - frequency: 'monthly', - patterns: [ - { type: 'day', value: 15 }, - { type: 'day', value: 3 }, - { type: 'day', value: 20 } - ] - }) + getRecurringDescription( + { + start: '2021-04-25', + frequency: 'monthly', + patterns: [ + { type: 'day', value: 15 }, + { type: 'day', value: 3 }, + { type: 'day', value: 20 } + ] + }, + i18n + ) ).toBe('Every month on the 3rd, 15th, and 20th'); expect( - getRecurringDescription({ - start: '2021-04-25', - frequency: 'monthly', - patterns: [ - { type: 'day', value: 3 }, - { type: 'day', value: -1 }, - { type: 'day', value: 20 } - ] - }) + getRecurringDescription( + { + start: '2021-04-25', + frequency: 'monthly', + patterns: [ + { type: 'day', value: 3 }, + { type: 'day', value: -1 }, + { type: 'day', value: 20 } + ] + }, + i18n + ) ).toBe('Every month on the 3rd, 20th, and last day'); // Mix days and day names expect( - getRecurringDescription({ - start: '2021-04-25', - frequency: 'monthly', - patterns: [ - { type: 'day', value: 3 }, - { type: 'day', value: -1 }, - { type: 'FR', value: 2 } - ] - }) + getRecurringDescription( + { + start: '2021-04-25', + frequency: 'monthly', + patterns: [ + { type: 'day', value: 3 }, + { type: 'day', value: -1 }, + { type: 'FR', value: 2 } + ] + }, + i18n + ) ).toBe('Every month on the 2nd Friday, 3rd, and last day'); // When there is a mixture of types, day names should always come first expect( - getRecurringDescription({ - start: '2021-04-25', - frequency: 'monthly', - patterns: [ - { type: 'SA', value: 1 }, - { type: 'day', value: 2 }, - { type: 'FR', value: 3 }, - { type: 'day', value: 10 } - ] - }) + getRecurringDescription( + { + start: '2021-04-25', + frequency: 'monthly', + patterns: [ + { type: 'SA', value: 1 }, + { type: 'day', value: 2 }, + { type: 'FR', value: 3 }, + { type: 'day', value: 10 } + ] + }, + i18n + ) ).toBe('Every month on the 1st Saturday, 3rd Friday, 2nd, and 10th'); }); it('describes yearly interval', () => { expect( - getRecurringDescription({ start: '2021-05-17', frequency: 'yearly' }) + getRecurringDescription( + { start: '2021-05-17', frequency: 'yearly' }, + i18n + ) ).toBe('Every year on May 17th'); expect( - getRecurringDescription({ - start: '2021-05-17', - frequency: 'yearly', - interval: 2 - }) + getRecurringDescription( + { + start: '2021-05-17', + frequency: 'yearly', + interval: 2 + }, + i18n + ) ).toBe('Every 2 years on May 17th'); }); }); diff --git a/packages/loot-design/src/components/RecurringSchedulePicker.js b/packages/loot-design/src/components/RecurringSchedulePicker.js index 7e4b29d..f1fd23d 100644 --- a/packages/loot-design/src/components/RecurringSchedulePicker.js +++ b/packages/loot-design/src/components/RecurringSchedulePicker.js @@ -1,4 +1,5 @@ import React, { useEffect, useReducer, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { sendCatch } from 'loot-core/src/platform/client/fetch'; @@ -12,8 +13,6 @@ import SubtractIcon from 'loot-design/src/svg/Subtract'; import { Button, Select, Input, Tooltip, View, Text, Stack } from './common'; import DateSelect from './DateSelect'; -const DATE_FORMAT = 'yyyy-MM-dd'; - // ex: There is no 6th Friday of the Month const MAX_DAY_OF_WEEK_INTERVAL = 5; @@ -62,7 +61,7 @@ function unparseConfig(parsed) { function createMonthlyRecurrence(startDate) { return { - value: parseInt(monthUtils.format(startDate, 'd')), + value: parseInt(monthUtils.nonLocalizedFormat(startDate, 'd')), type: 'day' }; } @@ -152,8 +151,8 @@ function SchedulePreview({ previewDates }) { {previewDates.map(d => ( - {monthUtils.format(d, dateFormat)} - {monthUtils.format(d, 'EEEE')} + {monthUtils.nonLocalizedFormat(d, dateFormat)} + {monthUtils.nonLocalizedFormat(d, 'EEEE')} ))} @@ -370,6 +369,7 @@ export default function RecurringSchedulePicker({ onChange }) { let { isOpen, close, getOpenEvents } = useTooltip(); + let { i18n } = useTranslation(); function onSave(config) { onChange(config); @@ -379,7 +379,7 @@ export default function RecurringSchedulePicker({ return ( {isOpen && ( { function getCurrentMonthName(startMonth, currentMonth) { return monthUtils.getYear(startMonth) === monthUtils.getYear(currentMonth) - ? monthUtils.format(currentMonth, 'MMM') + ? monthUtils.nonLocalizedFormat(currentMonth, 'MMM') : null; } @@ -1264,7 +1264,7 @@ export const MonthPicker = scope(lively => { const currentMonth = monthUtils.currentMonth(); const range = getRangeForYear(currentMonth); const monthNames = range.map(month => { - return monthUtils.format(month, 'MMM'); + return monthUtils.nonLocalizedFormat(month, 'MMM'); }); return { @@ -1314,7 +1314,7 @@ export const MonthPicker = scope(lively => { flex: '0 0 40px' }} > - {monthUtils.format(year, 'yyyy')} + {monthUtils.nonLocalizedFormat(year, 'yyyy')} - {monthUtils.format(month, 'MMMM')} + {monthUtils.nonLocalizedFormat(month, 'MMMM')} - {monthUtils.format(month, 'MMMM')} + {monthUtils.nonLocalizedFormat(month, 'MMMM')} - {monthUtils.format(currentMonth, "MMMM ''yy")} + {monthUtils.nonLocalizedFormat(currentMonth, "MMMM ''yy")} {editMode ? (