From 84fef142b98decd8dc85eb4ae6b8dafbc2c44ed5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:25:52 +0200 Subject: [PATCH] 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. --- locales/rn/en.json | 24 ++++ .../__tests__/calendar-i18n.test.ts | 12 ++ src/components/calendar/CalendarEditSheet.tsx | 5 +- .../calendar/CalendarShareSheet.tsx | 17 ++- .../calendar/CalendarSidebarDrawer.tsx | 15 +- src/components/calendar/EventDetailSheet.tsx | 40 +++--- src/components/calendar/EventModal.tsx | 14 +- src/components/calendar/ICalImportSheet.tsx | 8 +- .../calendar/ICalSubscriptionSheet.tsx | 82 ++++++++--- src/components/calendar/ParticipantInput.tsx | 7 +- src/components/calendar/RecurrenceEditor.tsx | 51 ++++--- src/components/calendar/TasksSheet.tsx | 23 ++- src/lib/__tests__/recurrence-summary.test.ts | 32 +++++ src/lib/calendar-locale.ts | 4 +- src/lib/calendar-utils.ts | 22 +++ src/lib/recurrence.ts | 132 +++++++++++------- src/stores/calendar-subscriptions-store.ts | 16 ++- 17 files changed, 363 insertions(+), 141 deletions(-) create mode 100644 src/lib/__tests__/recurrence-summary.test.ts diff --git a/locales/rn/en.json b/locales/rn/en.json index 1e6415fd..1366b4ac 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -9,6 +9,10 @@ "alerts": { "unit_minutes_after": "minutes after" }, + "colors": { + "indigo": "Indigo", + "teal": "Teal" + }, "drawer": { "my_calendars": "My calendars", "no_calendars": "No calendars yet.", @@ -72,6 +76,15 @@ "edit_calendar": "Edit calendar", "new_calendar": "New calendar" }, + "recurrence": { + "editor_occurrences_count": "{count, plural, one {occurrence} other {occurrences}}", + "every_count_days": "{count, plural, one {Every # day} other {Every # days}}", + "every_count_months": "{count, plural, one {Every # month} other {Every # months}}", + "every_count_weeks": "{count, plural, one {Every # week} other {Every # weeks}}", + "every_count_years": "{count, plural, one {Every # year} other {Every # years}}", + "occurrence_count": "{count, plural, one {# occurrence} other {# occurrences}}", + "until_date": "Until {date}" + }, "settings": { "show_week_numbers_mobile_desc": "Display week numbers in the month view.", "time_zone": "Time zone", @@ -89,9 +102,20 @@ "shared_with": "Shared with", "title": "Share" }, + "subscription": { + "add_feed": "Add a feed", + "feed_too_large": "Feed is too large", + "fetch_failed": "Could not fetch feed (HTTP {status})", + "name_and_url_required": "Enter a name and a feed URL.", + "not_a_feed": "That URL did not return an iCalendar feed", + "not_synced": "Not synced yet", + "url_scheme_invalid": "URL must start with https://, http://, webcal:// or webcals://", + "your_subscriptions": "Your subscriptions" + }, "tasks": { "add_placeholder": "Add a task…", "add_time": "Add time", + "clear_due_date": "Clear due date", "delete_error": "Failed to delete task", "not_found": "This task is no longer available.", "save_error": "Failed to save task", diff --git a/src/components/__tests__/calendar-i18n.test.ts b/src/components/__tests__/calendar-i18n.test.ts index 9c6119c0..da636db7 100644 --- a/src/components/__tests__/calendar-i18n.test.ts +++ b/src/components/__tests__/calendar-i18n.test.ts @@ -16,6 +16,18 @@ const TRANSLATED_FILES: Record = { 'components/calendar/MonthView.tsx': [], 'components/calendar/TimeGridScrollView.tsx': [], 'components/calendar/WeekView.tsx': [], + 'components/calendar/CalendarEditSheet.tsx': [], + 'components/calendar/CalendarShareSheet.tsx': [], + 'components/calendar/CalendarSidebarDrawer.tsx': [], + 'components/calendar/EventDetailSheet.tsx': [], + // The meeting link placeholder shows an example URL. + 'components/calendar/EventModal.tsx': ['https://meet.example.com/…'], + 'components/calendar/ICalImportSheet.tsx': [], + 'components/calendar/ICalSubscriptionSheet.tsx': [], + 'components/calendar/ParticipantInput.tsx': [], + 'components/calendar/RecurrenceEditor.tsx': [], + 'components/calendar/RecurrenceScopeDialog.tsx': [], + 'components/calendar/TasksSheet.tsx': [], }; describe('translated calendar screens', () => { diff --git a/src/components/calendar/CalendarEditSheet.tsx b/src/components/calendar/CalendarEditSheet.tsx index 571f5c23..c2b3fbe4 100644 --- a/src/components/calendar/CalendarEditSheet.tsx +++ b/src/components/calendar/CalendarEditSheet.tsx @@ -15,7 +15,7 @@ import type { Calendar } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; -import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils'; +import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils'; import Button from '../Button'; export interface CalendarEditValues { @@ -98,6 +98,9 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend key={col} onPress={() => setColor(col)} style={[styles.swatch, { backgroundColor: col }, active && styles.swatchActive]} + accessibilityRole="radio" + accessibilityLabel={calendarColorName(col, t)} + accessibilityState={{ selected: active }} > {active && } diff --git a/src/components/calendar/CalendarShareSheet.tsx b/src/components/calendar/CalendarShareSheet.tsx index 8262d471..15c89621 100644 --- a/src/components/calendar/CalendarShareSheet.tsx +++ b/src/components/calendar/CalendarShareSheet.tsx @@ -40,11 +40,12 @@ const CALENDAR_PRESETS: Record = { }, }; +// The webmail's sharing presets (same rights, same names). const PRESET_LABEL_KEYS: Record = { - freeBusy: ['calendar.share.role_free_busy', 'Free/busy'], - read: ['calendar.share.role_viewer', 'Viewer'], - readWrite: ['calendar.share.role_editor', 'Editor'], - manager: ['calendar.share.role_manager', 'Manager'], + freeBusy: ['sharing.preset.freeBusy', 'Free/busy only'], + read: ['sharing.preset.read', 'Read only'], + readWrite: ['sharing.preset.readWrite', 'Read & write'], + manager: ['sharing.preset.manager', 'Manager'], }; const PRESET_ORDER: RolePreset[] = ['freeBusy', 'read', 'readWrite', 'manager']; @@ -159,7 +160,7 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare - {t('calendar.share.title', 'Share')} “{calendar.name}” + {t('sharing.title', 'Share "{name}"', { name: calendar.name })} @@ -180,6 +181,8 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare onPress={() => void applyShare(principalId, CALENDAR_PRESETS[p])} disabled={busy} style={[styles.chip, preset === p && styles.chipActive]} + accessibilityRole="button" + accessibilityState={{ selected: preset === p, disabled: busy }} > {t(PRESET_LABEL_KEYS[p][0], PRESET_LABEL_KEYS[p][1])} @@ -187,13 +190,15 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare ))} {preset === 'custom' ? ( - {t('calendar.share.role_custom', 'Custom')} + {t('sharing.preset.custom', 'Custom')} ) : null} void applyShare(principalId, null)} disabled={busy} hitSlop={8} style={styles.removeBtn} + accessibilityRole="button" + accessibilityLabel={t('sharing.remove', 'Remove access')} > {busy ? ( diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx index 97fa70f9..c332b42f 100644 --- a/src/components/calendar/CalendarSidebarDrawer.tsx +++ b/src/components/calendar/CalendarSidebarDrawer.tsx @@ -18,7 +18,7 @@ import type { Calendar } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useAnimDuration } from '../../theme/dynamic'; -import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils'; +import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils'; import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar'; import { isWritableCalendar } from '../../lib/calendar-editability'; import { useLocaleStore } from '../../stores/locale-store'; @@ -152,7 +152,13 @@ export function CalendarSidebarDrawer({ > - + {t('calendar.my_calendars', 'Calendars')} @@ -274,6 +280,8 @@ function Section({ styles.row, pressed && styles.rowPressed, ]} + accessibilityRole="checkbox" + accessibilityState={{ checked: visible }} > {active && } diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index 02365934..7c43b1ed 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -52,6 +52,8 @@ import { getEventEditability } from '../../lib/calendar-editability'; import { useContactNameResolver } from '../../lib/contact-name-resolver'; import { useCalendarLocale } from '../../lib/calendar-locale'; import { useSheetDrag } from '../../lib/use-sheet-drag'; +import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrence'; +import type { TranslateFn } from '../../stores/locale-store'; type RsvpStatus = 'accepted' | 'declined' | 'tentative'; @@ -100,30 +102,14 @@ function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, l return `${format(start, `MMM d, ${tp}`, opts)} – ${format(end, `MMM d, ${tp}`, opts)}`; } -type Translate = (key: string, fallback?: string) => string; - -function recurrenceLabel(event: CalendarEvent, t: Translate, locale: Locale): string | null { +function recurrenceLabel(event: CalendarEvent, t: TranslateFn, locale: Locale): string | null { const rule = event.recurrenceRules?.[0]; if (!rule) return null; const freq = rule.frequency.toLowerCase(); - const unitKey: Record = { - daily: ['calendar.recurrence.every_n_days', 'Every {count} days'], - weekly: ['calendar.recurrence.every_n_weeks', 'Every {count} weeks'], - monthly: ['calendar.recurrence.every_n_months', 'Every {count} months'], - yearly: ['calendar.recurrence.every_n_years', 'Every {count} years'], - }; - let label: string; - if (rule.interval && rule.interval > 1 && unitKey[freq]) { - label = t(unitKey[freq][0], unitKey[freq][1]).replace('{count}', String(rule.interval)); - } else { - label = t(`calendar.recurrence.${freq}`, rule.frequency); - } - if (rule.until) { - label += ` · ${t('calendar.recurrence.until', 'Until')} ${format(new Date(rule.until), 'MMM d, yyyy', { locale })}`; - } else if (rule.count) { - label += ` · ${t('calendar.recurrence.occurrences', '{count} occurrences').replace('{count}', String(rule.count))}`; - } - return label; + const label = recurrenceIntervalLabel(freq, rule.interval ?? 1, t) + ?? t(`calendar.recurrence.${freq}`, rule.frequency); + const end = recurrenceEndLabel(rule, t, locale); + return end ? `${label} · ${end}` : label; } export function EventDetailSheet({ @@ -257,7 +243,13 @@ export function EventDetailSheet({ {calendar && {calendar.name}} - + @@ -362,7 +354,7 @@ export function EventDetailSheet({ ]} /> - {p.name || p.email || 'Unknown'} + {p.name || p.email || t('common.unknown', 'Unknown')} {p.isOrganizer ? ( {' '}({t('calendar.participants.organizer', 'Organizer').toLowerCase()}) @@ -520,7 +512,7 @@ function statusColor(c: ThemePalette, status?: string): string { } } -function statusLabel(status: string | undefined, t: Translate): string { +function statusLabel(status: string | undefined, t: TranslateFn): string { switch (status) { case 'accepted': return t('calendar.participants.accepted', 'Accepted'); diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 435c163f..9ab782b4 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -387,7 +387,7 @@ export function EventModal({ const recurrenceOption = RECURRENCE_OPTIONS.find((o) => o.value === recurrence); const recurrenceLabel = recurrence === 'custom' - ? (customRule && buildRecurrenceSummary(customRule)) || t('calendar.recurrence.custom', 'Custom…') + ? (customRule && buildRecurrenceSummary(customRule, t, locale)) || t('calendar.recurrence.custom', 'Custom…') : recurrenceOption ? t(recurrenceOption.key, recurrenceOption.fallback) : t('calendar.recurrence.none', 'Does not repeat'); @@ -416,7 +416,13 @@ export function EventModal({ > - + @@ -501,6 +507,7 @@ export function EventModal({ @@ -608,6 +615,8 @@ export function EventModal({ onPress={() => removeReminder(r.minutesBefore)} hitSlop={8} style={styles.reminderRemove} + accessibilityRole="button" + accessibilityLabel={t('calendar.alerts.remove', 'Remove reminder')} > @@ -739,6 +748,7 @@ export function EventModal({ diff --git a/src/components/calendar/ICalImportSheet.tsx b/src/components/calendar/ICalImportSheet.tsx index a84304f5..e928ff48 100644 --- a/src/components/calendar/ICalImportSheet.tsx +++ b/src/components/calendar/ICalImportSheet.tsx @@ -192,7 +192,13 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor {t('calendar.import.title', 'Import Calendar')} - + diff --git a/src/components/calendar/ICalSubscriptionSheet.tsx b/src/components/calendar/ICalSubscriptionSheet.tsx index e0efcc7e..4d7955a9 100644 --- a/src/components/calendar/ICalSubscriptionSheet.tsx +++ b/src/components/calendar/ICalSubscriptionSheet.tsx @@ -8,7 +8,8 @@ import { formatDistanceToNow } from 'date-fns'; import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { CALENDAR_COLOR_PALETTE } from '../../lib/calendar-utils'; +import { CALENDAR_COLOR_PALETTE, calendarColorName } from '../../lib/calendar-utils'; +import { useCalendarLocale } from '../../lib/calendar-locale'; import { DEFAULT_REFRESH_INTERVAL_MINUTES, selectAccountSubscriptions, @@ -17,11 +18,12 @@ import { } from '../../stores/calendar-subscriptions-store'; import { jmapClient } from '../../api/jmap-client'; -const INTERVAL_OPTIONS: { minutes: number; label: string }[] = [ - { minutes: 15, label: '15 min' }, - { minutes: 60, label: '1 h' }, - { minutes: 360, label: '6 h' }, - { minutes: 1440, label: '1 day' }, +// The webmail's refresh intervals and their labels. +const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [ + { minutes: 15, key: 'calendar.subscription.interval_15', fallback: 'Every 15 minutes' }, + { minutes: 60, key: 'calendar.subscription.interval_60', fallback: 'Every hour' }, + { minutes: 360, key: 'calendar.subscription.interval_360', fallback: 'Every 6 hours' }, + { minutes: 1440, key: 'calendar.subscription.interval_1440', fallback: 'Every day' }, ]; interface Props { @@ -33,6 +35,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const insets = useSafeAreaInsets(); + const { locale, t } = useCalendarLocale(); const allSubscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions); // Only the signed-in account's feeds: a sub created under another account // mirrors into a calendar id that doesn't exist (or collides) here. @@ -98,11 +101,11 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { const n = name.trim(); const u = url.trim(); if (!n || !u) { - setFormError('Enter a name and a feed URL.'); + setFormError(t('calendar.subscription.name_and_url_required', 'Enter a name and a feed URL.')); return; } if (!/^(https?|webcals?):\/\//i.test(u)) { - setFormError('URL must start with https://, http://, webcal:// or webcals://'); + setFormError(t('calendar.subscription.url_scheme_invalid', 'URL must start with https://, http://, webcal:// or webcals://')); return; } setFormError(null); @@ -115,7 +118,13 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { } resetForm(); } catch (e) { - setFormError(e instanceof Error ? e.message : 'Could not add subscription.'); + setFormError( + e instanceof Error && e.message + ? e.message + : editingId + ? t('calendar.subscription.update_error', 'Failed to update subscription') + : t('calendar.subscription.error', 'Failed to add subscription'), + ); } finally { setAdding(false); } @@ -130,26 +139,38 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { - Calendar subscriptions - + {t('calendar.subscription.section_title', 'iCal Subscriptions')} + - {editingId ? 'Edit subscription' : 'Add a feed'} + + {editingId + ? t('calendar.subscription.edit_title', 'Edit Subscription') + : t('calendar.subscription.add_feed', 'Add a feed')} + ))} - Refresh every + {t('calendar.subscription.refresh_interval', 'Refresh interval')} {INTERVAL_OPTIONS.map((opt) => { const active = interval === opt.minutes; @@ -177,9 +201,11 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { key={opt.minutes} onPress={() => setInterval(opt.minutes)} style={[styles.intervalChip, active && styles.intervalChipActive]} + accessibilityRole="radio" + accessibilityState={{ selected: active }} > - {opt.label} + {t(opt.key, opt.fallback)} ); @@ -194,7 +220,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { {editingId && ( )} {subscriptions.length > 0 && ( <> - Your subscriptions + + {t('calendar.subscription.your_subscriptions', 'Your subscriptions')} + {subscriptions.map((sub) => { const busy = !!syncing[sub.id]; return ( @@ -220,8 +254,10 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { {sub.lastError ? sub.lastError : sub.lastSyncAt - ? `Synced ${formatDistanceToNow(sub.lastSyncAt, { addSuffix: true })}` - : 'Not synced yet'} + ? t('calendar.subscription.last_refreshed', 'Last updated: {time}', { + time: formatDistanceToNow(sub.lastSyncAt, { addSuffix: true, locale }), + }) + : t('calendar.subscription.not_synced', 'Not synced yet')} {busy ? ( @@ -240,6 +278,8 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { onPress={() => { void removeSubscription(sub.id); }} hitSlop={6} style={styles.subBtn} + accessibilityRole="button" + accessibilityLabel={t('calendar.subscription.unsubscribe', 'Unsubscribe')} > diff --git a/src/components/calendar/ParticipantInput.tsx b/src/components/calendar/ParticipantInput.tsx index b4d8e612..47881f5c 100644 --- a/src/components/calendar/ParticipantInput.tsx +++ b/src/components/calendar/ParticipantInput.tsx @@ -93,7 +93,12 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu {a.name || a.email} - onRemove(a.email)} hitSlop={6}> + onRemove(a.email)} + hitSlop={6} + accessibilityRole="button" + accessibilityLabel={t('calendar.participants.remove', 'Remove')} + > diff --git a/src/components/calendar/RecurrenceEditor.tsx b/src/components/calendar/RecurrenceEditor.tsx index 946622d2..4a6f20a4 100644 --- a/src/components/calendar/RecurrenceEditor.tsx +++ b/src/components/calendar/RecurrenceEditor.tsx @@ -7,9 +7,10 @@ import type { RecurrenceRule } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { Button } from '..'; +import { useCalendarLocale } from '../../lib/calendar-locale'; import { EDITOR_FREQUENCIES, - UNIT_LABELS, + UNIT_LABEL_KEYS, WEEKDAYS, buildRecurrenceSummary, buildRuleFromEditorValue, @@ -80,11 +81,13 @@ function NumberInput({ min, max, onChange, + accessibilityLabel, }: { value: number; min: number; max: number; onChange: (value: number) => void; + accessibilityLabel?: string; }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -102,6 +105,7 @@ function NumberInput({ }} keyboardType="number-pad" style={styles.numberInput} + accessibilityLabel={accessibilityLabel} /> ); } @@ -109,6 +113,7 @@ function NumberInput({ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const { locale, t } = useCalendarLocale(); const [value, setValue] = React.useState(() => editorValueFromRule(rule, eventStart), @@ -130,22 +135,23 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre }; const builtRule = buildRuleFromEditorValue(value, eventStart); - const summary = buildRecurrenceSummary(builtRule); + const summary = buildRecurrenceSummary(builtRule, t, locale); return ( - Every + {t('calendar.recurrence.editor_every', 'Every')} update({ interval })} + accessibilityLabel={t('calendar.recurrence.editor_every', 'Every')} /> flex value={value.frequency} - options={EDITOR_FREQUENCIES.map((f) => ({ value: f, label: UNIT_LABELS[f] }))} + options={EDITOR_FREQUENCIES.map((f) => ({ value: f, label: t(UNIT_LABEL_KEYS[f][0], UNIT_LABEL_KEYS[f][1]) }))} onChange={(frequency) => update({ frequency })} /> @@ -159,9 +165,12 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre key={day} onPress={() => toggleWeekDay(day)} style={[styles.weekdayChip, active && styles.weekdayChipActive]} + accessibilityRole="checkbox" + accessibilityLabel={weekdayName(day, 'long', locale)} + accessibilityState={{ checked: active }} > - {weekdayName(day, 'short')} + {weekdayName(day, 'short', locale)} ); @@ -171,13 +180,13 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre {value.frequency === 'yearly' && ( - In + {capitalize(t('calendar.recurrence.editor_in', 'in'))} flex value={value.month} options={Array.from({ length: 12 }, (_, i) => i + 1).map((m) => ({ value: m, - label: capitalize(monthName(m)), + label: capitalize(monthName(m, locale)), }))} onChange={(month) => update({ month })} /> @@ -189,8 +198,8 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre value={value.monthlyMode} options={[ - { value: 'day', label: 'On day' }, - { value: 'nth', label: 'On the' }, + { value: 'day', label: capitalize(t('calendar.recurrence.editor_on_day', 'on day')) }, + { value: 'nth', label: capitalize(t('calendar.recurrence.editor_on_the', 'on the')) }, ]} onChange={(monthlyMode) => update({ monthlyMode })} /> @@ -200,19 +209,20 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre min={1} max={31} onChange={(monthDay) => update({ monthDay })} + accessibilityLabel={t('calendar.recurrence.editor_on_day', 'on day')} /> ) : ( <> flex value={value.nth} - options={[1, 2, 3, 4, -1].map((n) => ({ value: n, label: capitalize(nthLabel(n)) }))} + options={[1, 2, 3, 4, -1].map((n) => ({ value: n, label: capitalize(nthLabel(n, t)) }))} onChange={(nth) => update({ nth })} /> flex value={value.nthDay} - options={WEEKDAYS.map((d) => ({ value: d, label: capitalize(weekdayName(d)) }))} + options={WEEKDAYS.map((d) => ({ value: d, label: capitalize(weekdayName(d, 'long', locale)) }))} onChange={(nthDay) => update({ nthDay })} /> @@ -221,21 +231,21 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre )} - Ends + {t('calendar.recurrence.editor_ends', 'Ends')} flex={value.endsMode === 'never'} value={value.endsMode} options={[ - { value: 'never', label: 'Never' }, - { value: 'on', label: 'On date' }, - { value: 'after', label: 'After' }, + { value: 'never', label: t('calendar.recurrence.editor_never', 'Never') }, + { value: 'on', label: t('calendar.recurrence.editor_ends_on', 'On') }, + { value: 'after', label: t('calendar.recurrence.editor_ends_after', 'After') }, ]} onChange={(endsMode) => update({ endsMode })} /> {value.endsMode === 'on' && ( setShowUntilPicker(true)}> - {format(parseISO(value.untilDate), 'MMM d, yyyy')} + {format(parseISO(value.untilDate), 'MMM d, yyyy', { locale })} )} @@ -246,8 +256,11 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre min={1} max={999} onChange={(count) => update({ count })} + accessibilityLabel={t('calendar.recurrence.editor_ends_after', 'After')} /> - occurrences + + {t('calendar.recurrence.editor_occurrences_count', '{count, plural, one {occurrence} other {occurrences}}', { count: value.count })} + )} @@ -258,10 +271,10 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx index 317ad61e..55d96dd7 100644 --- a/src/components/calendar/TasksSheet.tsx +++ b/src/components/calendar/TasksSheet.tsx @@ -241,7 +241,13 @@ export function TasksSheet({ {t('calendar.tasks.label', 'Tasks')} - + @@ -262,6 +268,8 @@ export function TasksSheet({ onPress={() => { void handleSave(); }} disabled={!editor.title.trim() || !editor.calendarId || saving} style={[styles.addBtn, (!editor.title.trim() || saving) && styles.addBtnDisabled]} + accessibilityRole="button" + accessibilityLabel={editor.id ? t('calendar.tasks.save', 'Save') : t('calendar.tasks.create', 'New Task')} > {editor.id ? : } @@ -290,6 +298,8 @@ export function TasksSheet({ setEditor((e) => ({ ...e, due: null, withTime: false }))} + accessibilityRole="button" + accessibilityLabel={t('calendar.tasks.clear_due_date', 'Clear due date')} > @@ -383,6 +393,9 @@ export function TasksSheet({ onPress={() => { void onToggle(task.id); }} style={[styles.checkbox, completed && styles.checkboxChecked]} hitSlop={8} + accessibilityRole="checkbox" + accessibilityLabel={task.title || t('calendar.tasks.no_title', '(No title)')} + accessibilityState={{ checked: completed }} > {completed && } @@ -407,7 +420,13 @@ export function TasksSheet({ {due} )} - { void onDelete(task.id); }} hitSlop={8} style={styles.taskDelete}> + { void onDelete(task.id); }} + hitSlop={8} + style={styles.taskDelete} + accessibilityRole="button" + accessibilityLabel={t('calendar.tasks.delete', 'Delete')} + > diff --git a/src/lib/__tests__/recurrence-summary.test.ts b/src/lib/__tests__/recurrence-summary.test.ts new file mode 100644 index 00000000..43913f8d --- /dev/null +++ b/src/lib/__tests__/recurrence-summary.test.ts @@ -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 => ({ '@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(); + }); +}); diff --git a/src/lib/calendar-locale.ts b/src/lib/calendar-locale.ts index c1b4d52d..3461cb23 100644 --- a/src/lib/calendar-locale.ts +++ b/src/lib/calendar-locale.ts @@ -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 }; diff --git a/src/lib/calendar-utils.ts b/src/lib/calendar-utils.ts index 166f5dd0..e03b8874 100644 --- a/src/lib/calendar-utils.ts +++ b/src/lib/calendar-utils.ts @@ -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++) { diff --git a/src/lib/recurrence.ts b/src/lib/recurrence.ts index 9ff14af1..b7fd465b 100644 --- a/src/lib/recurrence.ts +++ b/src/lib/recurrence.ts @@ -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 = { 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 = { - 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 = { + 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 = { - 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 { diff --git a/src/stores/calendar-subscriptions-store.ts b/src/stores/calendar-subscriptions-store.ts index 51b04fbe..8853757a 100644 --- a/src/stores/calendar-subscriptions-store.ts +++ b/src/stores/calendar-subscriptions-store.ts @@ -15,6 +15,7 @@ import { import { jmapClient } from '../api/jmap-client'; import { uploadBytes } from '../api/blob'; import { useCalendarStore } from './calendar-store'; +import { t } from './locale-store'; export interface CalendarSubscription { id: string; @@ -100,13 +101,16 @@ function base64(input: string): string { async function fetchAndParseFeed(url: string): Promise[]> { const { url: feedUrl, headers } = normalizeFeedUrl(url); const res = await fetch(feedUrl, { headers }); - if (!res.ok) throw new Error(`Could not fetch feed (HTTP ${res.status})`); + if (!res.ok) { + throw new Error(t('calendar.subscription.fetch_failed', 'Could not fetch feed (HTTP {status})', { status: res.status })); + } + const tooLarge = () => new Error(t('calendar.subscription.feed_too_large', 'Feed is too large')); const length = Number(res.headers.get('content-length') || 0); - if (length > MAX_FEED_BYTES) throw new Error('Feed is too large'); + if (length > MAX_FEED_BYTES) throw tooLarge(); const text = await res.text(); - if (text.length > MAX_FEED_BYTES) throw new Error('Feed is too large'); + if (text.length > MAX_FEED_BYTES) throw tooLarge(); if (!/BEGIN:VCALENDAR/i.test(text)) { - throw new Error('That URL did not return an iCalendar feed'); + throw new Error(t('calendar.subscription.not_a_feed', 'That URL did not return an iCalendar feed')); } const bytes = new TextEncoder().encode(text); const { blobId } = await uploadBytes(bytes, 'text/calendar'); @@ -177,7 +181,7 @@ export const useCalendarSubscriptionsStore = create()( // best-effort } await useCalendarStore.getState().fetchCalendars(); - throw err instanceof Error ? err : new Error('Sync failed'); + throw err instanceof Error ? err : new Error(t('calendar.subscription.error', 'Failed to add subscription')); } finally { set({ syncing: { ...get().syncing, [sub.id]: false } }); } @@ -234,7 +238,7 @@ export const useCalendarSubscriptionsStore = create()( ), }); } catch (err) { - const message = err instanceof Error ? err.message : 'Sync failed'; + const message = err instanceof Error ? err.message : t('calendar.subscription.refresh_error', 'Failed to refresh subscription'); set({ subscriptions: get().subscriptions.map((s) => s.id === id ? { ...s, lastError: message } : s,