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,