Implement localization for schedule descriptions (#225)

* 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 <tom@tomfren.ch>
This commit is contained in:
Jed Fox 2022-09-08 09:37:45 -04:00 committed by GitHub
parent 6fb497dec5
commit 5217835c55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 376 additions and 169 deletions

View file

@ -15,4 +15,4 @@ jobs:
- name: Set up environment - name: Set up environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Check i18n keys - 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

View file

@ -2,6 +2,7 @@ module.exports = {
input: ['src/**/*.js'], input: ['src/**/*.js'],
output: 'src/locales/$LOCALE.json', output: 'src/locales/$LOCALE.json',
locales: ['en-GB', 'es-ES'], locales: ['en-GB', 'es-ES'],
defaultNamespace: 'web',
sort: true, sort: true,
// Force usage of JsxLexer for .js files as otherwise we can't pick up <Trans> components. // Force usage of JsxLexer for .js files as otherwise we can't pick up <Trans> components.
lexers: { lexers: {

View file

@ -327,7 +327,7 @@ class Budget extends React.PureComponent {
pathname: '/accounts', pathname: '/accounts',
state: { state: {
goBack: true, goBack: true,
filterName: `${categoryName} (${monthUtils.format( filterName: `${categoryName} (${monthUtils.nonLocalizedFormat(
month, month,
'MMMM yyyy' 'MMMM yyyy'
)})`, )})`,

View file

@ -259,7 +259,7 @@ function ScheduleDescription({ id }) {
</Text> </Text>
<Text style={{ margin: '0 5px' }}> </Text> <Text style={{ margin: '0 5px' }}> </Text>
<Text style={{ flexShrink: 0 }}> <Text style={{ flexShrink: 0 }}>
Next: {monthUtils.format(schedule.next_date, dateFormat)} Next: {monthUtils.nonLocalizedFormat(schedule.next_date, dateFormat)}
</Text> </Text>
</View> </View>
<StatusBadge status={status} /> <StatusBadge status={status} />

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { format as formatDate, parseISO } from 'date-fns'; import { format as formatDate, parseISO } from 'date-fns';
@ -52,6 +53,7 @@ export function Value({
data: dataProp, data: dataProp,
describe = x => x.name describe = x => x.name
}) { }) {
const { i18n } = useTranslation();
let { data, dateFormat } = useSelector(state => { let { data, dateFormat } = useSelector(state => {
let data; let data;
if (dataProp) { if (dataProp) {
@ -95,7 +97,7 @@ export function Value({
} else if (field === 'date') { } else if (field === 'date') {
if (value) { if (value) {
if (value.frequency) { if (value.frequency) {
return getRecurringDescription(value); return getRecurringDescription(value, i18n);
} }
return formatDate(parseISO(value), dateFormat); return formatDate(parseISO(value), dateFormat);
} }

View file

@ -53,7 +53,7 @@ function CashFlow() {
.rangeInclusive(earliestMonth, monthUtils.currentMonth()) .rangeInclusive(earliestMonth, monthUtils.currentMonth())
.map(month => ({ .map(month => ({
name: month, name: month,
pretty: monthUtils.format(month, 'MMMM, yyyy') pretty: monthUtils.nonLocalizedFormat(month, 'MMMM, yyyy')
})) }))
.reverse(); .reverse();

View file

@ -51,7 +51,7 @@ function NetWorth({ accounts }) {
.rangeInclusive(earliestMonth, monthUtils.currentMonth()) .rangeInclusive(earliestMonth, monthUtils.currentMonth())
.map(month => ({ .map(month => ({
name: month, name: month,
pretty: monthUtils.format(month, 'MMMM, yyyy') pretty: monthUtils.nonLocalizedFormat(month, 'MMMM, yyyy')
})) }))
.reverse(); .reverse();

View file

@ -36,12 +36,12 @@ let ROW_HEIGHT = 43;
function DiscoverSchedulesTable({ schedules, loading }) { function DiscoverSchedulesTable({ schedules, loading }) {
let selectedItems = useSelectedItems(); let selectedItems = useSelectedItems();
let dispatchSelected = useSelectedDispatch(); let dispatchSelected = useSelectedDispatch();
const { t } = useTranslation(); let { t, i18n } = useTranslation();
function renderItem({ item }) { function renderItem({ item }) {
let selected = selectedItems.has(item.id); let selected = selectedItems.has(item.id);
let amountOp = item._conditions.find(c => c.field === 'amount').op; let amountOp = item._conditions.find(c => c.field === 'amount').op;
let recurDescription = getRecurringDescription(item.date); let recurDescription = getRecurringDescription(item.date, i18n);
return ( return (
<Row <Row

View file

@ -549,7 +549,9 @@ export default function ScheduleDetails() {
style={{ marginTop: 10, color: colors.n4 }} style={{ marginTop: 10, color: colors.n4 }}
> >
{state.upcomingDates.map(date => ( {state.upcomingDates.map(date => (
<View>{monthUtils.format(date, `${dateFormat} EEEE`)}</View> <View>
{monthUtils.nonLocalizedFormat(date, `${dateFormat} EEEE`)}
</View>
))} ))}
</Stack> </Stack>
</View> </View>

View file

@ -181,7 +181,7 @@ export function SchedulesTable({
</Field> </Field>
<Field width={110}> <Field width={110}>
{item.next_date {item.next_date
? monthUtils.format(item.next_date, dateFormat) ? monthUtils.nonLocalizedFormat(item.next_date, dateFormat)
: null} : null}
</Field> </Field>
<Field width={120} style={{ alignItems: 'flex-start' }}> <Field width={120} style={{ alignItems: 'flex-start' }}>

View file

@ -16,7 +16,7 @@ import Navigation from './Navigation';
function Overspending({ navigationProps, stepTwo }) { function Overspending({ navigationProps, stepTwo }) {
let currentMonth = monthUtils.currentMonth(); let currentMonth = monthUtils.currentMonth();
let sheetName = monthUtils.sheetForMonth(currentMonth); let sheetName = monthUtils.sheetForMonth(currentMonth);
let month = monthUtils.format(currentMonth, 'MMM'); let month = monthUtils.nonLocalizedFormat(currentMonth, 'MMM');
let [minimized, toggle] = useMinimized(); let [minimized, toggle] = useMinimized();
return ( return (

View file

@ -2,15 +2,20 @@ import { initReactI18next } from 'react-i18next';
import i18n from '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 enUK from './en-GB.json';
import esES from './es-ES.json'; import esES from './es-ES.json';
const resources = { const resources = {
en: { en: {
translation: enUK web: enUK,
core: enUKCore
}, },
es: { es: {
translation: esES web: esES,
core: esESCore
} }
}; };
@ -18,6 +23,7 @@ i18n
.use(initReactI18next) // passes i18n down to react-i18next .use(initReactI18next) // passes i18n down to react-i18next
.init({ .init({
resources, resources,
defaultNS: 'web',
lng: 'es', // language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources 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 // 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 // if you're using a language detector, do not define the lng option

View file

@ -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 <Trans> components.
lexers: {
js: ['JsxLexer'],
ts: ['JsxLexer'],
jsx: ['JsxLexer'],
tsx: ['JsxLexer'],
default: ['JsxLexer']
}
};

View file

@ -12,7 +12,8 @@
"lint": "eslint src", "lint": "eslint src",
"test": "npm-run-all -cp 'test:*'", "test": "npm-run-all -cp 'test:*'",
"test:node": "jest -c jest.config.js", "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": "", "author": "",
"license": "ISC", "license": "ISC",
@ -56,6 +57,8 @@
"fake-indexeddb": "^3.1.3", "fake-indexeddb": "^3.1.3",
"fast-check": "2.13.0", "fast-check": "2.13.0",
"fast-glob": "^2.2.0", "fast-glob": "^2.2.0",
"i18next": "^21.9.1",
"i18next-parser": "^6.5.0",
"jest": "^28.1.0", "jest": "^28.1.0",
"jsverify": "^0.8.4", "jsverify": "^0.8.4",
"lru-cache": "^5.1.1", "lru-cache": "^5.1.1",

View file

@ -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}}"
}
}
}

View file

@ -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}}"
}
}
}

View file

@ -210,11 +210,11 @@ export function sheetForMonth(month) {
return 'budget' + month.replace('-', ''); return 'budget' + month.replace('-', '');
} }
export function nameForMonth(month) { export function format(month, opts, locale) {
return d.format(_parse(month), "MMMM 'yy"); return Intl.DateTimeFormat(locale, opts).format(_parse(month));
} }
export function format(month, str) { export function nonLocalizedFormat(month, str) {
return d.format(_parse(month), str); return d.format(_parse(month), str);
} }

View file

@ -42,39 +42,49 @@ export function getHasTransactionsQuery(schedules) {
.select(['schedule', 'date']); .select(['schedule', 'date']);
} }
function makeNumberSuffix(num) { function prettyDayName(day, locale) {
// 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) {
let days = { let days = {
SU: 'Sunday', SU: new Date('2020-01-05T12:00:00.000Z'),
MO: 'Monday', MO: new Date('2020-01-06T12:00:00.000Z'),
TU: 'Tuesday', TU: new Date('2020-01-07T12:00:00.000Z'),
WE: 'Wednesday', WE: new Date('2020-01-08T12:00:00.000Z'),
TH: 'Thursday', TH: new Date('2020-01-09T12:00:00.000Z'),
FR: 'Friday', FR: new Date('2020-01-10T12:00:00.000Z'),
SA: 'Saturday' 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; let interval = config.interval || 1;
switch (config.frequency) { switch (config.frequency) {
case 'weekly': { case 'weekly': {
let desc = 'Every '; return i18n.t('schedules.recurring.weekly', {
desc += interval !== 1 ? `${interval} weeks` : 'week'; count: interval,
desc += ' on ' + monthUtils.format(config.start, 'EEEE'); day: monthUtils.format(
return desc; config.start,
{ weekday: 'long' },
i18n.resolvedLanguage
),
ns: 'core'
});
} }
case 'monthly': { case 'monthly': {
let desc = 'Every ';
desc += interval !== 1 ? `${interval} months` : 'month';
if (config.patterns && config.patterns.length > 0) { if (config.patterns && config.patterns.length > 0) {
// Sort the days ascending. We filter out -1 because that // Sort the days ascending. We filter out -1 because that
// represents "last days" and should always be last, but this // 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 // Add on all -1 values to the end
patterns = patterns.concat(config.patterns.filter(p => p.value === -1)); patterns = patterns.concat(config.patterns.filter(p => p.value === -1));
desc += ' on the ';
let strs = []; let strs = [];
let uniqueDays = new Set(patterns.map(p => p.type)); 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) { for (let pattern of patterns) {
if (pattern.type === 'day') { if (pattern.type === 'day') {
if (pattern.value === -1) { if (pattern.value === -1) {
strs.push('last day'); strs.push(
i18n.t('schedules.recurring.pattern.lastDay', {
ns: 'core'
})
);
} else { } else {
// Example: 15th day strs.push(
strs.push(makeNumberSuffix(pattern.value)); i18n.t('general.ordinal', {
count: pattern.value,
ordinal: true,
ns: 'core'
})
);
} }
} else { } else {
let dayName = isSameDay ? '' : ' ' + prettyDayName(pattern.type); let dayName = prettyDayName(
pattern.type,
i18n.resolvedLanguage,
i18n.resolvedLanguage
);
if (pattern.value === -1) { if (pattern.value === -1) {
// Example: last Monday // 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 { } else {
// Example: 3rd Monday // 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) { return i18n.t('schedules.recurring.monthlyPattern', {
desc += strs.slice(0, strs.length - 1).join(', '); context,
desc += ', and '; count: interval,
desc += strs[strs.length - 1]; day: prettyDayName(patterns[0].type, i18n.resolvedLanguage),
} else { pattern: new Intl.ListFormat(i18n.resolvedLanguage, {
desc += strs.join(' and '); style: 'long',
} type: 'conjunction'
}).format(strs),
if (isSameDay) { ns: 'core'
desc += ' ' + prettyDayName(patterns[0].type); });
}
} else { } 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': { case 'yearly': {
let desc = 'Every '; return i18n.t(
desc += interval !== 1 ? `${interval} years` : 'year'; 'schedules.recurring.yearly',
desc += ' on ' + monthUtils.format(config.start, 'LLL do'); {
return desc; count: interval,
day: formatMonthAndDay(config.start, i18n),
ns: 'core'
},
i18n.resolvedLanguage
);
} }
default: default:
return 'Recurring error'; return 'Recurring error';

View file

@ -1,7 +1,18 @@
import i18n from 'i18next';
import MockDate from 'mockdate'; import MockDate from 'mockdate';
import enUKCore from '../locales/en-GB.json';
import { getRecurringDescription } from './schedules'; import { getRecurringDescription } from './schedules';
i18n.init({
lng: 'en',
resources: {
en: {
core: enUKCore
}
}
});
describe('recurring date description', () => { describe('recurring date description', () => {
beforeEach(() => { beforeEach(() => {
MockDate.set(new Date(2021, 4, 14)); MockDate.set(new Date(2021, 4, 14));
@ -9,149 +20,197 @@ describe('recurring date description', () => {
it('describes weekly interval', () => { it('describes weekly interval', () => {
expect( expect(
getRecurringDescription({ start: '2021-05-17', frequency: 'weekly' }) getRecurringDescription(
{ start: '2021-05-17', frequency: 'weekly' },
i18n
)
).toBe('Every week on Monday'); ).toBe('Every week on Monday');
expect( expect(
getRecurringDescription({ getRecurringDescription(
start: '2021-05-17', {
frequency: 'weekly', start: '2021-05-17',
interval: 2 frequency: 'weekly',
}) interval: 2
},
i18n
)
).toBe('Every 2 weeks on Monday'); ).toBe('Every 2 weeks on Monday');
}); });
it('describes monthly interval', () => { it('describes monthly interval', () => {
expect( expect(
getRecurringDescription({ start: '2021-04-25', frequency: 'monthly' }) getRecurringDescription(
{ start: '2021-04-25', frequency: 'monthly' },
i18n
)
).toBe('Every month on the 25th'); ).toBe('Every month on the 25th');
expect( expect(
getRecurringDescription({ getRecurringDescription(
start: '2021-04-25', {
frequency: 'monthly', start: '2021-04-25',
interval: 2 frequency: 'monthly',
}) interval: 2
},
i18n
)
).toBe('Every 2 months on the 25th'); ).toBe('Every 2 months on the 25th');
expect( expect(
getRecurringDescription({ getRecurringDescription(
start: '2021-04-25', {
frequency: 'monthly', start: '2021-04-25',
patterns: [{ type: 'day', value: 25 }] frequency: 'monthly',
}) patterns: [{ type: 'day', value: 25 }]
},
i18n
)
).toBe('Every month on the 25th'); ).toBe('Every month on the 25th');
expect( expect(
getRecurringDescription({ getRecurringDescription(
start: '2021-04-25', {
frequency: 'monthly', start: '2021-04-25',
interval: 2, frequency: 'monthly',
patterns: [{ type: 'day', value: 25 }] interval: 2,
}) patterns: [{ type: 'day', value: 25 }]
},
i18n
)
).toBe('Every 2 months on the 25th'); ).toBe('Every 2 months on the 25th');
// Last day should work // Last day should work
expect( expect(
getRecurringDescription({ getRecurringDescription(
start: '2021-04-25', {
frequency: 'monthly', start: '2021-04-25',
patterns: [{ type: 'day', value: 31 }] frequency: 'monthly',
}) patterns: [{ type: 'day', value: 31 }]
},
i18n
)
).toBe('Every month on the 31st'); ).toBe('Every month on the 31st');
// -1 should work, representing the last day // -1 should work, representing the last day
expect( expect(
getRecurringDescription({ getRecurringDescription(
start: '2021-04-25', {
frequency: 'monthly', start: '2021-04-25',
patterns: [{ type: 'day', value: -1 }] frequency: 'monthly',
}) patterns: [{ type: 'day', value: -1 }]
},
i18n
)
).toBe('Every month on the last day'); ).toBe('Every month on the last day');
// Day names should work // Day names should work
expect( expect(
getRecurringDescription({ getRecurringDescription(
start: '2021-04-25', {
frequency: 'monthly', start: '2021-04-25',
patterns: [{ type: 'FR', value: 2 }] frequency: 'monthly',
}) patterns: [{ type: 'FR', value: 2 }]
},
i18n
)
).toBe('Every month on the 2nd Friday'); ).toBe('Every month on the 2nd Friday');
expect( expect(
getRecurringDescription({ getRecurringDescription(
start: '2021-04-25', {
frequency: 'monthly', start: '2021-04-25',
patterns: [{ type: 'FR', value: -1 }] frequency: 'monthly',
}) patterns: [{ type: 'FR', value: -1 }]
},
i18n
)
).toBe('Every month on the last Friday'); ).toBe('Every month on the last Friday');
}); });
it('describes monthly interval with multiple days', () => { it('describes monthly interval with multiple days', () => {
// Note how order doesn't matter - the day should be sorted // Note how order doesn't matter - the day should be sorted
expect( expect(
getRecurringDescription({ getRecurringDescription(
start: '2021-04-25', {
frequency: 'monthly', start: '2021-04-25',
patterns: [ frequency: 'monthly',
{ type: 'day', value: 15 }, patterns: [
{ type: 'day', value: 3 }, { type: 'day', value: 15 },
{ type: 'day', value: 20 } { type: 'day', value: 3 },
] { type: 'day', value: 20 }
}) ]
},
i18n
)
).toBe('Every month on the 3rd, 15th, and 20th'); ).toBe('Every month on the 3rd, 15th, and 20th');
expect( expect(
getRecurringDescription({ getRecurringDescription(
start: '2021-04-25', {
frequency: 'monthly', start: '2021-04-25',
patterns: [ frequency: 'monthly',
{ type: 'day', value: 3 }, patterns: [
{ type: 'day', value: -1 }, { type: 'day', value: 3 },
{ type: 'day', value: 20 } { type: 'day', value: -1 },
] { type: 'day', value: 20 }
}) ]
},
i18n
)
).toBe('Every month on the 3rd, 20th, and last day'); ).toBe('Every month on the 3rd, 20th, and last day');
// Mix days and day names // Mix days and day names
expect( expect(
getRecurringDescription({ getRecurringDescription(
start: '2021-04-25', {
frequency: 'monthly', start: '2021-04-25',
patterns: [ frequency: 'monthly',
{ type: 'day', value: 3 }, patterns: [
{ type: 'day', value: -1 }, { type: 'day', value: 3 },
{ type: 'FR', value: 2 } { type: 'day', value: -1 },
] { type: 'FR', value: 2 }
}) ]
},
i18n
)
).toBe('Every month on the 2nd Friday, 3rd, and last day'); ).toBe('Every month on the 2nd Friday, 3rd, and last day');
// When there is a mixture of types, day names should always come first // When there is a mixture of types, day names should always come first
expect( expect(
getRecurringDescription({ getRecurringDescription(
start: '2021-04-25', {
frequency: 'monthly', start: '2021-04-25',
patterns: [ frequency: 'monthly',
{ type: 'SA', value: 1 }, patterns: [
{ type: 'day', value: 2 }, { type: 'SA', value: 1 },
{ type: 'FR', value: 3 }, { type: 'day', value: 2 },
{ type: 'day', value: 10 } { type: 'FR', value: 3 },
] { type: 'day', value: 10 }
}) ]
},
i18n
)
).toBe('Every month on the 1st Saturday, 3rd Friday, 2nd, and 10th'); ).toBe('Every month on the 1st Saturday, 3rd Friday, 2nd, and 10th');
}); });
it('describes yearly interval', () => { it('describes yearly interval', () => {
expect( expect(
getRecurringDescription({ start: '2021-05-17', frequency: 'yearly' }) getRecurringDescription(
{ start: '2021-05-17', frequency: 'yearly' },
i18n
)
).toBe('Every year on May 17th'); ).toBe('Every year on May 17th');
expect( expect(
getRecurringDescription({ getRecurringDescription(
start: '2021-05-17', {
frequency: 'yearly', start: '2021-05-17',
interval: 2 frequency: 'yearly',
}) interval: 2
},
i18n
)
).toBe('Every 2 years on May 17th'); ).toBe('Every 2 years on May 17th');
}); });
}); });

View file

@ -1,4 +1,5 @@
import React, { useEffect, useReducer, useState } from 'react'; import React, { useEffect, useReducer, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { sendCatch } from 'loot-core/src/platform/client/fetch'; 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 { Button, Select, Input, Tooltip, View, Text, Stack } from './common';
import DateSelect from './DateSelect'; import DateSelect from './DateSelect';
const DATE_FORMAT = 'yyyy-MM-dd';
// ex: There is no 6th Friday of the Month // ex: There is no 6th Friday of the Month
const MAX_DAY_OF_WEEK_INTERVAL = 5; const MAX_DAY_OF_WEEK_INTERVAL = 5;
@ -62,7 +61,7 @@ function unparseConfig(parsed) {
function createMonthlyRecurrence(startDate) { function createMonthlyRecurrence(startDate) {
return { return {
value: parseInt(monthUtils.format(startDate, 'd')), value: parseInt(monthUtils.nonLocalizedFormat(startDate, 'd')),
type: 'day' type: 'day'
}; };
} }
@ -152,8 +151,8 @@ function SchedulePreview({ previewDates }) {
<Stack direction="row" spacing={4} style={{ marginTop: 10 }}> <Stack direction="row" spacing={4} style={{ marginTop: 10 }}>
{previewDates.map(d => ( {previewDates.map(d => (
<View> <View>
<Text>{monthUtils.format(d, dateFormat)}</Text> <Text>{monthUtils.nonLocalizedFormat(d, dateFormat)}</Text>
<Text>{monthUtils.format(d, 'EEEE')}</Text> <Text>{monthUtils.nonLocalizedFormat(d, 'EEEE')}</Text>
</View> </View>
))} ))}
</Stack> </Stack>
@ -370,6 +369,7 @@ export default function RecurringSchedulePicker({
onChange onChange
}) { }) {
let { isOpen, close, getOpenEvents } = useTooltip(); let { isOpen, close, getOpenEvents } = useTooltip();
let { i18n } = useTranslation();
function onSave(config) { function onSave(config) {
onChange(config); onChange(config);
@ -379,7 +379,7 @@ export default function RecurringSchedulePicker({
return ( return (
<View> <View>
<Button {...getOpenEvents()} style={[{ textAlign: 'left' }, buttonStyle]}> <Button {...getOpenEvents()} style={[{ textAlign: 'left' }, buttonStyle]}>
{value ? getRecurringDescription(value) : 'No recurring date'} {value ? getRecurringDescription(value, i18n) : 'No recurring date'}
</Button> </Button>
{isOpen && ( {isOpen && (
<RecurringScheduleTooltip <RecurringScheduleTooltip

View file

@ -1256,7 +1256,7 @@ export const MonthPicker = scope(lively => {
function getCurrentMonthName(startMonth, currentMonth) { function getCurrentMonthName(startMonth, currentMonth) {
return monthUtils.getYear(startMonth) === monthUtils.getYear(currentMonth) return monthUtils.getYear(startMonth) === monthUtils.getYear(currentMonth)
? monthUtils.format(currentMonth, 'MMM') ? monthUtils.nonLocalizedFormat(currentMonth, 'MMM')
: null; : null;
} }
@ -1264,7 +1264,7 @@ export const MonthPicker = scope(lively => {
const currentMonth = monthUtils.currentMonth(); const currentMonth = monthUtils.currentMonth();
const range = getRangeForYear(currentMonth); const range = getRangeForYear(currentMonth);
const monthNames = range.map(month => { const monthNames = range.map(month => {
return monthUtils.format(month, 'MMM'); return monthUtils.nonLocalizedFormat(month, 'MMM');
}); });
return { return {
@ -1314,7 +1314,7 @@ export const MonthPicker = scope(lively => {
flex: '0 0 40px' flex: '0 0 40px'
}} }}
> >
{monthUtils.format(year, 'yyyy')} {monthUtils.nonLocalizedFormat(year, 'yyyy')}
</View> </View>
<ElementQuery <ElementQuery
sizes={[ sizes={[

View file

@ -321,7 +321,7 @@ export default React.memo(function BudgetSummary({ month }) {
currentMonth === month && { textDecoration: 'underline' } currentMonth === month && { textDecoration: 'underline' }
])} ])}
> >
{monthUtils.format(month, 'MMMM')} {monthUtils.nonLocalizedFormat(month, 'MMMM')}
</div> </div>
<View <View

View file

@ -271,7 +271,10 @@ export default React.memo(function BudgetSummary({ month }) {
setMenuOpen(false); setMenuOpen(false);
} }
let prevMonthName = monthUtils.format(monthUtils.prevMonth(month), 'MMM'); let prevMonthName = monthUtils.nonLocalizedFormat(
monthUtils.prevMonth(month),
'MMM'
);
let ExpandOrCollapseIcon = collapsed ? ArrowButtonDown1 : ArrowButtonUp1; let ExpandOrCollapseIcon = collapsed ? ArrowButtonDown1 : ArrowButtonUp1;
@ -337,7 +340,7 @@ export default React.memo(function BudgetSummary({ month }) {
currentMonth === month && { textDecoration: 'underline' } currentMonth === month && { textDecoration: 'underline' }
])} ])}
> >
{monthUtils.format(month, 'MMMM')} {monthUtils.nonLocalizedFormat(month, 'MMMM')}
</div> </div>
<View <View

View file

@ -1092,7 +1092,7 @@ export function BudgetHeader({
} }
]} ]}
> >
{monthUtils.format(currentMonth, "MMMM ''yy")} {monthUtils.nonLocalizedFormat(currentMonth, "MMMM ''yy")}
</Text> </Text>
{editMode ? ( {editMode ? (
<Button <Button

View file

@ -692,7 +692,7 @@ export function DateHeader({ date }) {
}} }}
> >
<Text style={[styles.text, { fontSize: 13, color: colors.n4 }]}> <Text style={[styles.text, { fontSize: 13, color: colors.n4 }]}>
{monthUtils.format(date, 'MMMM dd, yyyy')} {monthUtils.nonLocalizedFormat(date, 'MMMM dd, yyyy')}
</Text> </Text>
</ListItem> </ListItem>
); );

View file

@ -4,7 +4,7 @@ import { connect } from 'react-redux';
import * as d from 'date-fns'; import * as d from 'date-fns';
import * as actions from 'loot-core/src/client/actions'; import * as actions from 'loot-core/src/client/actions';
import { format as formatDate_ } from 'loot-core/src/shared/months'; import { nonLocalizedFormat as formatDate_ } from 'loot-core/src/shared/months';
import { import {
amountToCurrency, amountToCurrency,
amountToInteger, amountToInteger,

View file

@ -28,7 +28,10 @@ import {
} from 'loot-core/src/shared/categories.js'; } from 'loot-core/src/shared/categories.js';
function BudgetSummary({ month, onClose }) { function BudgetSummary({ month, onClose }) {
const prevMonthName = monthUtils.format(monthUtils.prevMonth(month), 'MMM'); const prevMonthName = monthUtils.nonLocalizedFormat(
monthUtils.prevMonth(month),
'MMM'
);
return ( return (
<NamespaceContext.Provider value={monthUtils.sheetForMonth(month)}> <NamespaceContext.Provider value={monthUtils.sheetForMonth(month)}>

View file

@ -15153,6 +15153,8 @@ jest-snapshot@test:
fast-check: 2.13.0 fast-check: 2.13.0
fast-glob: ^2.2.0 fast-glob: ^2.2.0
google-protobuf: ^3.12.0-rc.1 google-protobuf: ^3.12.0-rc.1
i18next: ^21.9.1
i18next-parser: ^6.5.0
jest: ^28.1.0 jest: ^28.1.0
jsverify: ^0.8.4 jsverify: ^0.8.4
lru-cache: ^5.1.1 lru-cache: ^5.1.1