mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
fix: translate the calendar sheets and label their buttons
- The custom recurrence editor and its summary were English throughout
(labels, units, weekday and month names, "on the third Thursday");
they now use the webmail's recurrence fragments and the app's date
locale. The interval and occurrence counts are ICU plurals, also in
the event details, which filled "Every {count} days" with
`.replace`.
- The iCal subscription sheet is translated with the webmail's
subscription strings, and its "last updated" time is relative in the
app language. The feed errors it shows come from the catalog too.
- The calendar share sheet asked for `calendar.share.role_*` keys no
catalog has; it now uses the webmail's sharing presets and title.
- Close, remove, refresh, unsubscribe, delete and new-task buttons get
screen reader labels, the event editor's switches are named, color
swatches read out their color, and task checkboxes and calendar rows
report whether they are checked.
The calendar i18n test now covers the sheets.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { translate, type MessageParams } from '../../i18n';
|
||||
import type { RecurrenceRule } from '../../api/types';
|
||||
import { buildRecurrenceSummary, recurrenceEndLabel, recurrenceIntervalLabel } from '../recurrence';
|
||||
|
||||
const t = (key: string, fallback?: string, params?: MessageParams) =>
|
||||
translate('en', key, fallback, params);
|
||||
|
||||
const rule = (r: Partial<RecurrenceRule>): RecurrenceRule => ({ '@type': 'RecurrenceRule', frequency: 'weekly', ...r });
|
||||
|
||||
describe('recurrence summary', () => {
|
||||
it('uses plurals for the interval and the occurrence count', () => {
|
||||
expect(recurrenceIntervalLabel('daily', 1, t)).toBe('Daily');
|
||||
expect(recurrenceIntervalLabel('weekly', 3, t)).toBe('Every 3 weeks');
|
||||
expect(recurrenceEndLabel(rule({ count: 1 }), t)).toBe('1 occurrence');
|
||||
expect(recurrenceEndLabel(rule({ count: 12 }), t)).toBe('12 occurrences');
|
||||
});
|
||||
|
||||
it('builds the summary from the catalog fragments', () => {
|
||||
expect(buildRecurrenceSummary(rule({
|
||||
interval: 2,
|
||||
byDay: [{ day: 'we' }, { day: 'mo' }],
|
||||
count: 5,
|
||||
}), t)).toBe('Every 2 weeks on Mon, Wed · 5 occurrences');
|
||||
expect(buildRecurrenceSummary(rule({
|
||||
frequency: 'monthly',
|
||||
byDay: [{ day: 'th', nthOfPeriod: 3 }],
|
||||
until: '2027-03-01T23:59:59',
|
||||
}), t)).toBe('Monthly on the third Thursday · Until Mar 1, 2027');
|
||||
expect(buildRecurrenceSummary(rule({ frequency: 'hourly' }), t)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locale } from 'date-fns';
|
||||
import { enUS } from 'date-fns/locale/en-US';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
|
||||
import type { LocaleCode } from '../i18n';
|
||||
|
||||
// date-fns locale for each UI language so month/day names, "EEE, MMM d"
|
||||
@@ -54,7 +54,7 @@ export function getDateFnsLocale(code: LocaleCode | string | null | undefined):
|
||||
}
|
||||
|
||||
/** `{ locale }` options for date-fns `format()` plus the translate function. */
|
||||
export function useCalendarLocale(): { locale: Locale; t: (key: string, fallback?: string) => string } {
|
||||
export function useCalendarLocale(): { locale: Locale; t: TranslateFn } {
|
||||
const code = useLocaleStore((s) => s.locale);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
return { locale: getDateFnsLocale(code), t };
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../api/types';
|
||||
import { colors } from '../theme/tokens';
|
||||
import type { TranslateFn } from '../stores/locale-store';
|
||||
import { zonedWallTimeToUtc } from './recurrence-expansion';
|
||||
import { getEffectiveTimeZone, localDateTimeToInstant, toDisplayDate } from './calendar-timezone';
|
||||
|
||||
@@ -500,6 +501,27 @@ const CALENDAR_PALETTE = [
|
||||
|
||||
export const CALENDAR_COLOR_PALETTE = CALENDAR_PALETTE;
|
||||
|
||||
/**
|
||||
* The name of a palette color ("Blue") for screen readers; any other color
|
||||
* as it is written.
|
||||
*/
|
||||
export function calendarColorName(color: string, t: TranslateFn): string {
|
||||
const index = CALENDAR_PALETTE.findIndex((c) => c.toLowerCase() === color.toLowerCase());
|
||||
if (index < 0) return color;
|
||||
// In palette order.
|
||||
const names = [
|
||||
t('email_viewer.color_tag.blue', 'Blue'),
|
||||
t('email_viewer.color_tag.green', 'Green'),
|
||||
t('email_viewer.color_tag.purple', 'Purple'),
|
||||
t('email_viewer.color_tag.orange', 'Orange'),
|
||||
t('email_viewer.color_tag.red', 'Red'),
|
||||
t('email_viewer.color_tag.pink', 'Pink'),
|
||||
t('calendar.colors.teal', 'Teal'),
|
||||
t('calendar.colors.indigo', 'Indigo'),
|
||||
];
|
||||
return names[index];
|
||||
}
|
||||
|
||||
function hashString(value: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
|
||||
+78
-54
@@ -4,8 +4,9 @@
|
||||
* construction, and the human-readable summary).
|
||||
*/
|
||||
|
||||
import { format } from 'date-fns';
|
||||
import { format, type Locale } from 'date-fns';
|
||||
import type { RecurrenceRule } from '../api/types';
|
||||
import type { TranslateFn } from '../stores/locale-store';
|
||||
|
||||
export type EditorFrequency = 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
export type MonthlyMode = 'day' | 'nth';
|
||||
@@ -19,33 +20,75 @@ export const WEEKDAYS: string[] = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'];
|
||||
const DAY_TO_REF_DATE: Record<string, number> = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 };
|
||||
export const INDEX_TO_DAY = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'];
|
||||
|
||||
export const UNIT_LABELS: Record<EditorFrequency, string> = {
|
||||
daily: 'days',
|
||||
weekly: 'weeks',
|
||||
monthly: 'months',
|
||||
yearly: 'years',
|
||||
// The editor's unit picker (webmail UNIT_LABEL_KEYS): key and English text.
|
||||
export const UNIT_LABEL_KEYS: Record<EditorFrequency, [string, string]> = {
|
||||
daily: ['calendar.recurrence.editor_unit_days', 'day(s)'],
|
||||
weekly: ['calendar.recurrence.editor_unit_weeks', 'week(s)'],
|
||||
monthly: ['calendar.recurrence.editor_unit_months', 'month(s)'],
|
||||
yearly: ['calendar.recurrence.editor_unit_years', 'year(s)'],
|
||||
};
|
||||
|
||||
// 2024-01-01 is a Monday — used to render weekday names via date-fns.
|
||||
export function weekdayName(day: string, style: 'long' | 'short' = 'long'): string {
|
||||
export function weekdayName(day: string, style: 'long' | 'short' = 'long', locale?: Locale): string {
|
||||
const ref = new Date(2024, 0, DAY_TO_REF_DATE[day] ?? 1);
|
||||
return format(ref, style === 'long' ? 'EEEE' : 'EEE');
|
||||
return format(ref, style === 'long' ? 'EEEE' : 'EEE', { locale });
|
||||
}
|
||||
|
||||
export function monthName(month: number): string {
|
||||
return format(new Date(2024, month - 1, 1), 'LLLL');
|
||||
export function monthName(month: number, locale?: Locale): string {
|
||||
return format(new Date(2024, month - 1, 1), 'LLLL', { locale });
|
||||
}
|
||||
|
||||
const NTH_LABELS: Record<number, string> = {
|
||||
1: 'first',
|
||||
2: 'second',
|
||||
3: 'third',
|
||||
4: 'fourth',
|
||||
[-1]: 'last',
|
||||
};
|
||||
export function nthLabel(nth: number, t: TranslateFn): string {
|
||||
switch (nth) {
|
||||
case 1: return t('calendar.recurrence.nth_1', 'first');
|
||||
case 2: return t('calendar.recurrence.nth_2', 'second');
|
||||
case 3: return t('calendar.recurrence.nth_3', 'third');
|
||||
case 4: return t('calendar.recurrence.nth_4', 'fourth');
|
||||
case -1: return t('calendar.recurrence.nth_last', 'last');
|
||||
default: return String(nth);
|
||||
}
|
||||
}
|
||||
|
||||
export function nthLabel(nth: number): string {
|
||||
return NTH_LABELS[nth] ?? String(nth);
|
||||
/**
|
||||
* "Daily" / "Every 3 weeks" for a rule's frequency and interval, or null for
|
||||
* a frequency without a label (hourly etc.).
|
||||
*/
|
||||
export function recurrenceIntervalLabel(frequency: string, interval: number, t: TranslateFn): string | null {
|
||||
const every = interval > 1;
|
||||
switch (frequency) {
|
||||
case 'daily':
|
||||
return every
|
||||
? t('calendar.recurrence.every_count_days', '{count, plural, one {Every # day} other {Every # days}}', { count: interval })
|
||||
: t('calendar.recurrence.daily', 'Daily');
|
||||
case 'weekly':
|
||||
return every
|
||||
? t('calendar.recurrence.every_count_weeks', '{count, plural, one {Every # week} other {Every # weeks}}', { count: interval })
|
||||
: t('calendar.recurrence.weekly', 'Weekly');
|
||||
case 'monthly':
|
||||
return every
|
||||
? t('calendar.recurrence.every_count_months', '{count, plural, one {Every # month} other {Every # months}}', { count: interval })
|
||||
: t('calendar.recurrence.monthly', 'Monthly');
|
||||
case 'yearly':
|
||||
return every
|
||||
? t('calendar.recurrence.every_count_years', '{count, plural, one {Every # year} other {Every # years}}', { count: interval })
|
||||
: t('calendar.recurrence.yearly', 'Yearly');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** "12 occurrences" / "Until Mar 1, 2027" for a rule that ends, else null. */
|
||||
export function recurrenceEndLabel(rule: RecurrenceRule, t: TranslateFn, locale?: Locale): string | null {
|
||||
if (rule.count) {
|
||||
return t('calendar.recurrence.occurrence_count', '{count, plural, one {# occurrence} other {# occurrences}}', { count: rule.count });
|
||||
}
|
||||
if (rule.until) {
|
||||
const d = new Date(rule.until);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return t('calendar.recurrence.until_date', 'Until {date}', { date: format(d, 'MMM d, yyyy', { locale }) });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function capitalize(s: string): string {
|
||||
@@ -86,28 +129,13 @@ export function isSimpleRecurrenceRule(rule: RecurrenceRule): boolean {
|
||||
|
||||
/**
|
||||
* Human-readable summary of a recurrence rule, e.g.
|
||||
* "Every 2 months on the third Thursday · 12 occurrences".
|
||||
* "Every 2 months on the third Thursday · 12 occurrences", in the app
|
||||
* language (from the same catalog fragments the webmail builds it from).
|
||||
* Returns null for frequencies the UI cannot describe (hourly etc.).
|
||||
*/
|
||||
export function buildRecurrenceSummary(rule: RecurrenceRule): string | null {
|
||||
const interval = rule.interval || 1;
|
||||
let base: string;
|
||||
switch (rule.frequency) {
|
||||
case 'daily':
|
||||
base = interval > 1 ? `Every ${interval} days` : 'Daily';
|
||||
break;
|
||||
case 'weekly':
|
||||
base = interval > 1 ? `Every ${interval} weeks` : 'Weekly';
|
||||
break;
|
||||
case 'monthly':
|
||||
base = interval > 1 ? `Every ${interval} months` : 'Monthly';
|
||||
break;
|
||||
case 'yearly':
|
||||
base = interval > 1 ? `Every ${interval} years` : 'Yearly';
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
export function buildRecurrenceSummary(rule: RecurrenceRule, t: TranslateFn, locale?: Locale): string | null {
|
||||
const base = recurrenceIntervalLabel(rule.frequency, rule.interval || 1, t);
|
||||
if (!base) return null;
|
||||
|
||||
const parts = [base];
|
||||
|
||||
@@ -115,34 +143,30 @@ export function buildRecurrenceSummary(rule: RecurrenceRule): string | null {
|
||||
const days = rule.byDay
|
||||
.filter((d) => WEEKDAYS.includes(d.day))
|
||||
.sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.day))
|
||||
.map((d) => weekdayName(d.day, 'short'))
|
||||
.map((d) => weekdayName(d.day, 'short', locale))
|
||||
.join(', ');
|
||||
if (days) parts.push(`on ${days}`);
|
||||
if (days) parts.push(t('calendar.recurrence.on_days', 'on {days}', { days }));
|
||||
}
|
||||
|
||||
if (rule.frequency === 'monthly' || rule.frequency === 'yearly') {
|
||||
if (rule.frequency === 'yearly' && rule.byMonth?.length) {
|
||||
const m = parseInt(rule.byMonth[0], 10);
|
||||
if (m >= 1 && m <= 12) parts.push(`in ${monthName(m)}`);
|
||||
if (m >= 1 && m <= 12) parts.push(t('calendar.recurrence.in_month', 'in {month}', { month: monthName(m, locale) }));
|
||||
}
|
||||
const nthDay = getNthDay(rule);
|
||||
if (nthDay) {
|
||||
parts.push(`on the ${nthLabel(nthDay.nth)} ${weekdayName(nthDay.day)}`);
|
||||
parts.push(t('calendar.recurrence.on_the_nth', 'on the {nth} {day}', {
|
||||
nth: nthLabel(nthDay.nth, t),
|
||||
day: weekdayName(nthDay.day, 'long', locale),
|
||||
}));
|
||||
} else if (rule.byMonthDay?.length) {
|
||||
parts.push(`on day ${rule.byMonthDay[0]}`);
|
||||
parts.push(t('calendar.recurrence.on_day_n', 'on day {day}', { day: rule.byMonthDay[0] }));
|
||||
}
|
||||
}
|
||||
|
||||
let summary = parts.join(' ');
|
||||
if (rule.count) {
|
||||
summary += ` · ${rule.count} ${rule.count === 1 ? 'occurrence' : 'occurrences'}`;
|
||||
} else if (rule.until) {
|
||||
const d = new Date(rule.until);
|
||||
if (!isNaN(d.getTime())) {
|
||||
summary += ` · until ${format(d, 'MMM d, yyyy')}`;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
const summary = parts.join(' ');
|
||||
const end = recurrenceEndLabel(rule, t, locale);
|
||||
return end ? `${summary} · ${end}` : summary;
|
||||
}
|
||||
|
||||
export interface RecurrenceEditorValue {
|
||||
|
||||
Reference in New Issue
Block a user