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:
parent
6fb497dec5
commit
5217835c55
28 changed files with 376 additions and 169 deletions
2
.github/workflows/i18n.yml
vendored
2
.github/workflows/i18n.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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 <Trans> components.
|
||||
lexers: {
|
||||
|
|
|
@ -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'
|
||||
)})`,
|
||||
|
|
|
@ -259,7 +259,7 @@ function ScheduleDescription({ id }) {
|
|||
</Text>
|
||||
<Text style={{ margin: '0 5px' }}> — </Text>
|
||||
<Text style={{ flexShrink: 0 }}>
|
||||
Next: {monthUtils.format(schedule.next_date, dateFormat)}
|
||||
Next: {monthUtils.nonLocalizedFormat(schedule.next_date, dateFormat)}
|
||||
</Text>
|
||||
</View>
|
||||
<StatusBadge status={status} />
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<Row
|
||||
|
|
|
@ -549,7 +549,9 @@ export default function ScheduleDetails() {
|
|||
style={{ marginTop: 10, color: colors.n4 }}
|
||||
>
|
||||
{state.upcomingDates.map(date => (
|
||||
<View>{monthUtils.format(date, `${dateFormat} EEEE`)}</View>
|
||||
<View>
|
||||
{monthUtils.nonLocalizedFormat(date, `${dateFormat} EEEE`)}
|
||||
</View>
|
||||
))}
|
||||
</Stack>
|
||||
</View>
|
||||
|
|
|
@ -181,7 +181,7 @@ export function SchedulesTable({
|
|||
</Field>
|
||||
<Field width={110}>
|
||||
{item.next_date
|
||||
? monthUtils.format(item.next_date, dateFormat)
|
||||
? monthUtils.nonLocalizedFormat(item.next_date, dateFormat)
|
||||
: null}
|
||||
</Field>
|
||||
<Field width={120} style={{ alignItems: 'flex-start' }}>
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
|
|
16
packages/loot-core/i18next-parser.config.js
Normal file
16
packages/loot-core/i18next-parser.config.js
Normal 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']
|
||||
}
|
||||
};
|
|
@ -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",
|
||||
|
|
27
packages/loot-core/src/locales/en-GB.json
Normal file
27
packages/loot-core/src/locales/en-GB.json
Normal 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}}"
|
||||
}
|
||||
}
|
||||
}
|
28
packages/loot-core/src/locales/es-ES.json
Normal file
28
packages/loot-core/src/locales/es-ES.json
Normal 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}}"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 }) {
|
|||
<Stack direction="row" spacing={4} style={{ marginTop: 10 }}>
|
||||
{previewDates.map(d => (
|
||||
<View>
|
||||
<Text>{monthUtils.format(d, dateFormat)}</Text>
|
||||
<Text>{monthUtils.format(d, 'EEEE')}</Text>
|
||||
<Text>{monthUtils.nonLocalizedFormat(d, dateFormat)}</Text>
|
||||
<Text>{monthUtils.nonLocalizedFormat(d, 'EEEE')}</Text>
|
||||
</View>
|
||||
))}
|
||||
</Stack>
|
||||
|
@ -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 (
|
||||
<View>
|
||||
<Button {...getOpenEvents()} style={[{ textAlign: 'left' }, buttonStyle]}>
|
||||
{value ? getRecurringDescription(value) : 'No recurring date'}
|
||||
{value ? getRecurringDescription(value, i18n) : 'No recurring date'}
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<RecurringScheduleTooltip
|
||||
|
|
|
@ -1256,7 +1256,7 @@ export const MonthPicker = scope(lively => {
|
|||
|
||||
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')}
|
||||
</View>
|
||||
<ElementQuery
|
||||
sizes={[
|
||||
|
|
|
@ -321,7 +321,7 @@ export default React.memo(function BudgetSummary({ month }) {
|
|||
currentMonth === month && { textDecoration: 'underline' }
|
||||
])}
|
||||
>
|
||||
{monthUtils.format(month, 'MMMM')}
|
||||
{monthUtils.nonLocalizedFormat(month, 'MMMM')}
|
||||
</div>
|
||||
|
||||
<View
|
||||
|
|
|
@ -271,7 +271,10 @@ export default React.memo(function BudgetSummary({ month }) {
|
|||
setMenuOpen(false);
|
||||
}
|
||||
|
||||
let prevMonthName = monthUtils.format(monthUtils.prevMonth(month), 'MMM');
|
||||
let prevMonthName = monthUtils.nonLocalizedFormat(
|
||||
monthUtils.prevMonth(month),
|
||||
'MMM'
|
||||
);
|
||||
|
||||
let ExpandOrCollapseIcon = collapsed ? ArrowButtonDown1 : ArrowButtonUp1;
|
||||
|
||||
|
@ -337,7 +340,7 @@ export default React.memo(function BudgetSummary({ month }) {
|
|||
currentMonth === month && { textDecoration: 'underline' }
|
||||
])}
|
||||
>
|
||||
{monthUtils.format(month, 'MMMM')}
|
||||
{monthUtils.nonLocalizedFormat(month, 'MMMM')}
|
||||
</div>
|
||||
|
||||
<View
|
||||
|
|
|
@ -1092,7 +1092,7 @@ export function BudgetHeader({
|
|||
}
|
||||
]}
|
||||
>
|
||||
{monthUtils.format(currentMonth, "MMMM ''yy")}
|
||||
{monthUtils.nonLocalizedFormat(currentMonth, "MMMM ''yy")}
|
||||
</Text>
|
||||
{editMode ? (
|
||||
<Button
|
||||
|
|
|
@ -692,7 +692,7 @@ export function DateHeader({ date }) {
|
|||
}}
|
||||
>
|
||||
<Text style={[styles.text, { fontSize: 13, color: colors.n4 }]}>
|
||||
{monthUtils.format(date, 'MMMM dd, yyyy')}
|
||||
{monthUtils.nonLocalizedFormat(date, 'MMMM dd, yyyy')}
|
||||
</Text>
|
||||
</ListItem>
|
||||
);
|
||||
|
|
|
@ -4,7 +4,7 @@ import { connect } from 'react-redux';
|
|||
import * as d from 'date-fns';
|
||||
|
||||
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 {
|
||||
amountToCurrency,
|
||||
amountToInteger,
|
||||
|
|
|
@ -28,7 +28,10 @@ import {
|
|||
} from 'loot-core/src/shared/categories.js';
|
||||
|
||||
function BudgetSummary({ month, onClose }) {
|
||||
const prevMonthName = monthUtils.format(monthUtils.prevMonth(month), 'MMM');
|
||||
const prevMonthName = monthUtils.nonLocalizedFormat(
|
||||
monthUtils.prevMonth(month),
|
||||
'MMM'
|
||||
);
|
||||
|
||||
return (
|
||||
<NamespaceContext.Provider value={monthUtils.sheetForMonth(month)}>
|
||||
|
|
|
@ -15153,6 +15153,8 @@ jest-snapshot@test:
|
|||
fast-check: 2.13.0
|
||||
fast-glob: ^2.2.0
|
||||
google-protobuf: ^3.12.0-rc.1
|
||||
i18next: ^21.9.1
|
||||
i18next-parser: ^6.5.0
|
||||
jest: ^28.1.0
|
||||
jsverify: ^0.8.4
|
||||
lru-cache: ^5.1.1
|
||||
|
|
Loading…
Reference in a new issue