Fix frequency translations (#8000)

* fix(translation): fall back to translation language for date/time locale

* fix(translation): use correct dative ordinal inflection in German monthly nth weekday settings

* fix(translation): use correct dative ordinal inflection in German monthly nth weekday settings

* fix(date-time): reset date-time locale to active UI language when override is removed

* fix(tasks): correct monthly repeat display precedence and validity checks

* refactor(tasks): use UTC-anchored Sunday for deterministic weekday-name lookups

* fix(tasks): standardize quick settings translation keys

* test(tasks): improve getTaskRepeatInfoText test coverage for monthly nth-weekday and fallbacks

* docs(i18n): explain dative form suffix for ORD_*_NTH translation keys

* translation(de): translate all missing keys in de.json to German

* fix(date-time): prevent crashes when DateAdapter is not provided or mocked

* fix(tasks): use correct dative ordinal inflection in monthly nth weekday quick settings
This commit is contained in:
Maikel Hajiabadi 2026-06-05 10:53:26 +02:00 committed by GitHub
parent 09437a7e7a
commit 25896fd318
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 741 additions and 408 deletions

View file

@ -16,6 +16,14 @@ Super Productivity uses JSON files for translations, located in `src/assets/i18n
**English (`en.json`) is the fallback language.** If a translation is missing or empty, the app automatically displays the English text.
### Suffixes for Inflected/Dative Forms (`_NTH`)
Some keys have duplicates with an `_NTH` suffix (e.g., `ORD_FIRST` vs `ORD_FIRST_NTH`).
- `ORD_FIRST` is used as a standalone option in the quick-setting menu (e.g., "first").
- `ORD_FIRST_NTH` is used inside full sentences (e.g., dative/inflected form in German or other inflected languages like "Monthly on the first Monday").
- In languages without inflection (like English), these values are identical.
### Empty Values Are Intentional
When you see empty strings (`""`), this is **intentional** - it triggers the English fallback. Do not copy the English text into empty fields unless you're providing an actual translation.

View file

@ -1,6 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { DateTimeFormatService } from './date-time-format.service';
import { provideMockStore } from '@ngrx/store/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const';
import { DateAdapter, MatNativeDateModule } from '@angular/material/core';
import {
@ -9,6 +9,7 @@ import {
} from 'src/app/core/locale.constants';
import { GlobalConfigState } from '../../features/config/global-config.model';
import { CustomDateAdapter } from './custom-date-adapter';
import { TranslateService } from '@ngx-translate/core';
describe('DateTimeFormatService', () => {
let service: DateTimeFormatService;
@ -31,6 +32,10 @@ describe('DateTimeFormatService', () => {
providers: [
DateTimeFormatService,
{ provide: DateAdapter, useClass: CustomDateAdapter },
{
provide: TranslateService,
useValue: { currentLang: 'en', defaultLang: 'en' },
},
provideMockStore({
initialState: {
globalConfig: config,
@ -49,6 +54,10 @@ describe('DateTimeFormatService', () => {
providers: [
DateTimeFormatService,
{ provide: DateAdapter, useClass: CustomDateAdapter },
{
provide: TranslateService,
useValue: { currentLang: 'en', defaultLang: 'en' },
},
provideMockStore({
initialState: {
globalConfig: DEFAULT_GLOBAL_CONFIG,
@ -231,4 +240,60 @@ describe('DateTimeFormatService', () => {
expect(formattedKoKr).toBe('오후 2:00');
});
});
describe('dateTimeLocale config fallback', () => {
let mockStore: MockStore;
beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [MatNativeDateModule],
providers: [
DateTimeFormatService,
{ provide: DateAdapter, useClass: CustomDateAdapter },
{
provide: TranslateService,
useValue: {
currentLang: 'fr',
defaultLang: 'en',
},
},
provideMockStore({
initialState: {
globalConfig: {
...DEFAULT_GLOBAL_CONFIG,
localization: {
...DEFAULT_GLOBAL_CONFIG.localization,
dateTimeLocale: 'de',
},
},
},
}),
],
});
mockStore = TestBed.inject(MockStore);
service = TestBed.inject(DateTimeFormatService);
});
it('should fall back to active UI language when dateTimeLocale is removed', () => {
// Initially, it should use the configured override 'de'
TestBed.flushEffects();
expect(service.currentLocale()).toBe('de');
// Now update config to remove the override (setting it to null)
mockStore.setState({
globalConfig: {
...DEFAULT_GLOBAL_CONFIG,
localization: {
...DEFAULT_GLOBAL_CONFIG.localization,
dateTimeLocale: null,
},
},
});
TestBed.flushEffects();
// It should fall back to currentLang 'fr'
expect(service.currentLocale()).toBe('fr');
});
});
});

View file

@ -1,18 +1,21 @@
import { computed, effect, inject, Injectable } from '@angular/core';
import { computed, effect, inject, Injectable, signal } from '@angular/core';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { DateAdapter } from '@angular/material/core';
import { DEFAULT_LOCALE, DateTimeLocale } from 'src/app/core/locale.constants';
import { TranslateService } from '@ngx-translate/core';
@Injectable({
providedIn: 'root',
})
export class DateTimeFormatService {
private readonly _globalConfigService = inject(GlobalConfigService);
private _dateAdapter = inject(DateAdapter);
private _dateAdapter = inject(DateAdapter, { optional: true });
private readonly _translateService = inject(TranslateService);
private readonly _localeSig = signal<DateTimeLocale>(DEFAULT_LOCALE);
// Signal for the locale to use
readonly currentLocale = computed<DateTimeLocale>(() => {
return this._globalConfigService.localization()?.dateTimeLocale || DEFAULT_LOCALE;
return this._globalConfigService.localization()?.dateTimeLocale || this._localeSig();
});
/** Test formats to detect locale-specific time and date formats (e.g., 24h vs 12h, DD/MM vs MM/DD) */
@ -55,13 +58,24 @@ export class DateTimeFormatService {
// Use effect to reactively update date adapter locale when config changes
effect(() => {
const cfgValue = this._globalConfigService.localization()?.dateTimeLocale;
if (cfgValue) this.setDateAdapterLocale(cfgValue);
if (cfgValue) {
this.setDateAdapterLocale(cfgValue);
} else {
const uiLang =
this._translateService.currentLang ||
this._translateService.defaultLang ||
DEFAULT_LOCALE;
this.setDateAdapterLocale(uiLang as DateTimeLocale);
}
});
}
/** Set the locale for the date adapter formatting */
setDateAdapterLocale(locale: DateTimeLocale): void {
this._dateAdapter.setLocale(locale);
if (this._dateAdapter && typeof this._dateAdapter.setLocale === 'function') {
this._dateAdapter.setLocale(locale);
}
this._localeSig.set(locale);
}
/**

View file

@ -149,6 +149,7 @@ export class TagListComponent {
repeatCfg,
this._dateTimeFormatService.currentLocale(),
this._dateTimeFormatService,
this._translateService,
);
chips.push({
title: this._translateService.instant(key, params),

View file

@ -39,6 +39,14 @@ describe('buildRepeatQuickSettingOptions', () => {
'YEARLY_CURRENT_DATE',
'CUSTOM',
]);
const monthlyNthWeekdayOption = options.find(
(o) => o.value === 'MONTHLY_NTH_WEEKDAY',
);
expect(monthlyNthWeekdayOption).toBeTruthy();
// Verify it uses the NTH/dative variant to ensure correct grammar in non-English locales
expect(monthlyNthWeekdayOption?.label).toContain('F.TASK_REPEAT.F.ORD_FIRST_NTH');
expect(monthlyNthWeekdayOption?.label).not.toContain('F.TASK_REPEAT.F.ORD_FIRST:');
});
// Regression test for #7945: a Date the form's `defaultValue` left unparsed

View file

@ -3,10 +3,10 @@ import { T } from '../../../t.const';
import { RepeatQuickSetting } from '../task-repeat-cfg.model';
const ORDINAL_KEYS = [
T.F.TASK_REPEAT.F.ORD_FIRST,
T.F.TASK_REPEAT.F.ORD_SECOND,
T.F.TASK_REPEAT.F.ORD_THIRD,
T.F.TASK_REPEAT.F.ORD_FOURTH,
T.F.TASK_REPEAT.F.ORD_FIRST_NTH,
T.F.TASK_REPEAT.F.ORD_SECOND_NTH,
T.F.TASK_REPEAT.F.ORD_THIRD_NTH,
T.F.TASK_REPEAT.F.ORD_FOURTH_NTH,
];
export const buildRepeatQuickSettingOptions = (

View file

@ -60,6 +60,7 @@ export class RepeatCfgPreviewComponent {
this.repeatCfg(),
this._dateTimeFormatService.currentLocale(),
this._dateTimeFormatService,
this._translateService,
);
return this._translateService.instant(key, params);
});

View file

@ -114,6 +114,7 @@ export class DialogViewArchivedTaskComponent {
repeatCfg,
this._dateTimeFormatService.currentLocale(),
this._dateTimeFormatService,
this._translateService,
);
this.repeatCfgLabel.set(this._translateService.instant(key, params));
}

View file

@ -4,6 +4,18 @@ import {
TaskRepeatCfg,
} from '../../task-repeat-cfg/task-repeat-cfg.model';
import { T } from '../../../t.const';
import { TranslateService } from '@ngx-translate/core';
const mockTranslateService = {
instant: (key: string) => {
if (key === T.F.TASK_REPEAT.F.ORD_FIRST_NTH) return 'first';
if (key === T.F.TASK_REPEAT.F.ORD_SECOND_NTH) return 'second';
if (key === T.F.TASK_REPEAT.F.ORD_THIRD_NTH) return 'third';
if (key === T.F.TASK_REPEAT.F.ORD_FOURTH_NTH) return 'fourth';
if (key === T.F.TASK_REPEAT.F.ORD_LAST_NTH) return 'last';
return key;
},
} as unknown as TranslateService;
describe('getTaskRepeatInfoText()', () => {
(
@ -156,6 +168,74 @@ describe('getTaskRepeatInfoText()', () => {
quickSetting: 'CUSTOM',
},
],
[
T.F.TASK_REPEAT.ADD_INFO_PANEL.MONTHLY_LAST_DAY,
{ timeStr: '' },
{
repeatEvery: 1,
repeatCycle: 'MONTHLY',
quickSetting: 'CUSTOM',
monthlyLastDay: true,
},
],
[
T.F.TASK_REPEAT.ADD_INFO_PANEL.MONTHLY_NTH_WEEKDAY,
{ timeStr: '', ordinalStr: 'first', weekdayStr: 'Saturday' },
{
repeatEvery: 1,
repeatCycle: 'MONTHLY',
quickSetting: 'CUSTOM',
monthlyWeekOfMonth: 1,
monthlyWeekday: 6,
},
],
[
T.F.TASK_REPEAT.ADD_INFO_PANEL.MONTHLY_NTH_WEEKDAY,
{ timeStr: '', ordinalStr: 'last', weekdayStr: 'Monday' },
{
repeatEvery: 1,
repeatCycle: 'MONTHLY',
quickSetting: 'CUSTOM',
monthlyWeekOfMonth: -1,
monthlyWeekday: 1,
},
],
[
T.F.TASK_REPEAT.ADD_INFO_PANEL.MONTHLY_NTH_WEEKDAY,
{ timeStr: '', ordinalStr: 'second', weekdayStr: 'Wednesday' },
{
repeatEvery: 1,
repeatCycle: 'MONTHLY',
quickSetting: 'CUSTOM',
monthlyWeekOfMonth: 2,
monthlyWeekday: 3,
monthlyLastDay: true,
},
],
[
T.F.TASK_REPEAT.ADD_INFO_PANEL.MONTHLY_CURRENT_DATE,
{ timeStr: '', dateDayStr: '24' },
{
repeatEvery: 1,
repeatCycle: 'MONTHLY',
quickSetting: 'CUSTOM',
startDate: '2022-02-24',
monthlyWeekOfMonth: 5,
monthlyWeekday: 6,
},
],
[
T.F.TASK_REPEAT.ADD_INFO_PANEL.MONTHLY_CURRENT_DATE,
{ timeStr: '', dateDayStr: '24' },
{
repeatEvery: 1,
repeatCycle: 'MONTHLY',
quickSetting: 'CUSTOM',
startDate: '2022-02-24',
monthlyWeekOfMonth: 1,
monthlyWeekday: undefined,
},
],
[
T.F.TASK_REPEAT.ADD_INFO_PANEL.CUSTOM_WEEKLY,
@ -191,6 +271,8 @@ describe('getTaskRepeatInfoText()', () => {
id: 'IDDD',
},
'en-US',
undefined,
mockTranslateService,
),
).toEqual([translationKey, translateParams]);
});
@ -208,6 +290,8 @@ describe('getTaskRepeatInfoText()', () => {
startTime: 'INVALID_CLOCK_STRING',
},
'en-US',
undefined,
mockTranslateService,
),
).not.toThrow();
});
@ -222,6 +306,8 @@ describe('getTaskRepeatInfoText()', () => {
startTime: 'INVALID_CLOCK_STRING',
},
'en-US',
undefined,
mockTranslateService,
);
expect(key).toBe(T.F.TASK_REPEAT.ADD_INFO_PANEL.DAILY);
expect(params).toEqual({ timeStr: '' });

View file

@ -9,11 +9,14 @@ import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
import { getWeekdaysMin } from '../../../util/get-weekdays-min';
import { DateTimeFormatService } from '../../../core/date-time-format/date-time-format.service';
import { getEffectiveRepeatStartDate } from '../../task-repeat-cfg/store/get-effective-repeat-start-date.util';
import { TranslateService } from '@ngx-translate/core';
import { hasNthWeekdayAnchor } from '../../task-repeat-cfg/store/get-nth-weekday-of-month.util';
export const getTaskRepeatInfoText = (
repeatCfg: TaskRepeatCfg,
locale: string | undefined,
dateTimeFormatService?: DateTimeFormatService,
dateTimeFormatService: DateTimeFormatService | undefined,
translateService: TranslateService,
): [string, { [key: string]: string | number }] => {
const timeStr =
repeatCfg.startTime && isValidSplitTime(repeatCfg.startTime)
@ -86,12 +89,10 @@ export const getTaskRepeatInfoText = (
const enabledDayIndex = TASK_REPEAT_WEEKDAY_MAP.findIndex(
(day) => repeatCfg[day],
);
const weekDayDate = new Date();
weekDayDate.setDate(
weekDayDate.getDate() + (enabledDayIndex - weekDayDate.getDay()),
);
const weekDayDate = new Date(Date.UTC(2026, 0, 4 + enabledDayIndex));
const weekdayStr = weekDayDate.toLocaleDateString(locale, {
weekday: 'short',
timeZone: 'UTC',
});
return [
timeStr
@ -131,6 +132,51 @@ export const getTaskRepeatInfoText = (
];
case 'MONTHLY':
if (hasNthWeekdayAnchor(repeatCfg)) {
const weekDayDate = new Date(Date.UTC(2026, 0, 4 + repeatCfg.monthlyWeekday));
const weekdayStr = weekDayDate.toLocaleDateString(locale, {
weekday: 'long',
timeZone: 'UTC',
});
let ordinalKey = '';
if (repeatCfg.monthlyWeekOfMonth === -1) {
ordinalKey = T.F.TASK_REPEAT.F.ORD_LAST_NTH;
} else {
const ordinalKeys = [
T.F.TASK_REPEAT.F.ORD_FIRST_NTH,
T.F.TASK_REPEAT.F.ORD_SECOND_NTH,
T.F.TASK_REPEAT.F.ORD_THIRD_NTH,
T.F.TASK_REPEAT.F.ORD_FOURTH_NTH,
];
ordinalKey = ordinalKeys[repeatCfg.monthlyWeekOfMonth - 1] || '';
}
if (ordinalKey) {
const ordinalStr = translateService.instant(ordinalKey);
return [
timeStr
? T.F.TASK_REPEAT.ADD_INFO_PANEL.MONTHLY_NTH_WEEKDAY_AND_TIME
: T.F.TASK_REPEAT.ADD_INFO_PANEL.MONTHLY_NTH_WEEKDAY,
{
ordinalStr,
weekdayStr,
timeStr,
},
];
}
}
if (repeatCfg.monthlyLastDay) {
return [
timeStr
? T.F.TASK_REPEAT.ADD_INFO_PANEL.MONTHLY_LAST_DAY_AND_TIME
: T.F.TASK_REPEAT.ADD_INFO_PANEL.MONTHLY_LAST_DAY,
{ timeStr },
];
}
const dateDayStr = dateStrToUtcDate(
getEffectiveRepeatStartDate(repeatCfg),
).toLocaleDateString(locale, {

View file

@ -213,6 +213,7 @@ export class TaskDetailPanelComponent implements OnInit, AfterViewInit, OnDestro
repeatCfg,
this._dateTimeFormatService.currentLocale(),
this._dateTimeFormatService,
this._translateService,
);
return this._translateService.instant(key, params);
}),

View file

@ -121,6 +121,7 @@ export class ScheduledListPageComponent {
repeatCfg,
this._dateTimeFormatService.currentLocale(),
this._dateTimeFormatService,
this._translateService,
);
return this._translateService.instant(key, params);
}

View file

@ -1884,6 +1884,12 @@ const T = {
MONTHLY_CURRENT_DATE: 'F.TASK_REPEAT.ADD_INFO_PANEL.MONTHLY_CURRENT_DATE',
MONTHLY_CURRENT_DATE_AND_TIME:
'F.TASK_REPEAT.ADD_INFO_PANEL.MONTHLY_CURRENT_DATE_AND_TIME',
MONTHLY_LAST_DAY: 'F.TASK_REPEAT.ADD_INFO_PANEL.MONTHLY_LAST_DAY',
MONTHLY_LAST_DAY_AND_TIME:
'F.TASK_REPEAT.ADD_INFO_PANEL.MONTHLY_LAST_DAY_AND_TIME',
MONTHLY_NTH_WEEKDAY: 'F.TASK_REPEAT.ADD_INFO_PANEL.MONTHLY_NTH_WEEKDAY',
MONTHLY_NTH_WEEKDAY_AND_TIME:
'F.TASK_REPEAT.ADD_INFO_PANEL.MONTHLY_NTH_WEEKDAY_AND_TIME',
WEEKLY_CURRENT_WEEKDAY: 'F.TASK_REPEAT.ADD_INFO_PANEL.WEEKLY_CURRENT_WEEKDAY',
WEEKLY_CURRENT_WEEKDAY_AND_TIME:
'F.TASK_REPEAT.ADD_INFO_PANEL.WEEKLY_CURRENT_WEEKDAY_AND_TIME',
@ -1934,10 +1940,15 @@ const T = {
MONTHLY_MODE_DAY_OF_MONTH: 'F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH',
NOTES: 'F.TASK_REPEAT.F.NOTES',
ORD_FIRST: 'F.TASK_REPEAT.F.ORD_FIRST',
ORD_FIRST_NTH: 'F.TASK_REPEAT.F.ORD_FIRST_NTH',
ORD_FOURTH: 'F.TASK_REPEAT.F.ORD_FOURTH',
ORD_FOURTH_NTH: 'F.TASK_REPEAT.F.ORD_FOURTH_NTH',
ORD_LAST: 'F.TASK_REPEAT.F.ORD_LAST',
ORD_LAST_NTH: 'F.TASK_REPEAT.F.ORD_LAST_NTH',
ORD_SECOND: 'F.TASK_REPEAT.F.ORD_SECOND',
ORD_SECOND_NTH: 'F.TASK_REPEAT.F.ORD_SECOND_NTH',
ORD_THIRD: 'F.TASK_REPEAT.F.ORD_THIRD',
ORD_THIRD_NTH: 'F.TASK_REPEAT.F.ORD_THIRD_NTH',
Q_CUSTOM: 'F.TASK_REPEAT.F.Q_CUSTOM',
Q_DAILY: 'F.TASK_REPEAT.F.Q_DAILY',
Q_MONDAY_TO_FRIDAY: 'F.TASK_REPEAT.F.Q_MONDAY_TO_FRIDAY',

File diff suppressed because it is too large Load diff

View file

@ -1837,6 +1837,10 @@
"MONDAY_TO_FRIDAY_AND_TIME": "Mon-Fri, {{timeStr}}",
"MONTHLY_CURRENT_DATE": "Monthly on day {{dateDayStr}}",
"MONTHLY_CURRENT_DATE_AND_TIME": "Monthly on day {{dateDayStr}}, {{timeStr}}",
"MONTHLY_LAST_DAY": "Monthly on the last day",
"MONTHLY_LAST_DAY_AND_TIME": "Monthly on the last day, {{timeStr}}",
"MONTHLY_NTH_WEEKDAY": "Monthly on the {{ordinalStr}} {{weekdayStr}}",
"MONTHLY_NTH_WEEKDAY_AND_TIME": "Monthly on the {{ordinalStr}} {{weekdayStr}}, {{timeStr}}",
"WEEKLY_CURRENT_WEEKDAY": "Weekly on {{weekdayStr}}",
"WEEKLY_CURRENT_WEEKDAY_AND_TIME": "Weekly on {{weekdayStr}}, {{timeStr}}",
"YEARLY_CURRENT_DATE": "Yearly on {{dayAndMonthStr}}",
@ -1884,10 +1888,15 @@
"MONTHLY_MODE_DAY_OF_MONTH": "Day of month",
"NOTES": "Default notes",
"ORD_FIRST": "first",
"ORD_FIRST_NTH": "first",
"ORD_FOURTH": "fourth",
"ORD_FOURTH_NTH": "fourth",
"ORD_LAST": "last",
"ORD_LAST_NTH": "last",
"ORD_SECOND": "second",
"ORD_SECOND_NTH": "second",
"ORD_THIRD": "third",
"ORD_THIRD_NTH": "third",
"Q_CUSTOM": "Custom recurring config",
"Q_DAILY": "Every day",
"Q_MONDAY_TO_FRIDAY": "Every Monday through Friday",