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:
@@ -9,6 +9,10 @@
|
|||||||
"alerts": {
|
"alerts": {
|
||||||
"unit_minutes_after": "minutes after"
|
"unit_minutes_after": "minutes after"
|
||||||
},
|
},
|
||||||
|
"colors": {
|
||||||
|
"indigo": "Indigo",
|
||||||
|
"teal": "Teal"
|
||||||
|
},
|
||||||
"drawer": {
|
"drawer": {
|
||||||
"my_calendars": "My calendars",
|
"my_calendars": "My calendars",
|
||||||
"no_calendars": "No calendars yet.",
|
"no_calendars": "No calendars yet.",
|
||||||
@@ -72,6 +76,15 @@
|
|||||||
"edit_calendar": "Edit calendar",
|
"edit_calendar": "Edit calendar",
|
||||||
"new_calendar": "New 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": {
|
"settings": {
|
||||||
"show_week_numbers_mobile_desc": "Display week numbers in the month view.",
|
"show_week_numbers_mobile_desc": "Display week numbers in the month view.",
|
||||||
"time_zone": "Time zone",
|
"time_zone": "Time zone",
|
||||||
@@ -89,9 +102,20 @@
|
|||||||
"shared_with": "Shared with",
|
"shared_with": "Shared with",
|
||||||
"title": "Share"
|
"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": {
|
"tasks": {
|
||||||
"add_placeholder": "Add a task…",
|
"add_placeholder": "Add a task…",
|
||||||
"add_time": "Add time",
|
"add_time": "Add time",
|
||||||
|
"clear_due_date": "Clear due date",
|
||||||
"delete_error": "Failed to delete task",
|
"delete_error": "Failed to delete task",
|
||||||
"not_found": "This task is no longer available.",
|
"not_found": "This task is no longer available.",
|
||||||
"save_error": "Failed to save task",
|
"save_error": "Failed to save task",
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ const TRANSLATED_FILES: Record<string, string[]> = {
|
|||||||
'components/calendar/MonthView.tsx': [],
|
'components/calendar/MonthView.tsx': [],
|
||||||
'components/calendar/TimeGridScrollView.tsx': [],
|
'components/calendar/TimeGridScrollView.tsx': [],
|
||||||
'components/calendar/WeekView.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', () => {
|
describe('translated calendar screens', () => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type { Calendar } from '../../api/types';
|
|||||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
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';
|
import Button from '../Button';
|
||||||
|
|
||||||
export interface CalendarEditValues {
|
export interface CalendarEditValues {
|
||||||
@@ -98,6 +98,9 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
|||||||
key={col}
|
key={col}
|
||||||
onPress={() => setColor(col)}
|
onPress={() => setColor(col)}
|
||||||
style={[styles.swatch, { backgroundColor: col }, active && styles.swatchActive]}
|
style={[styles.swatch, { backgroundColor: col }, active && styles.swatchActive]}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityLabel={calendarColorName(col, t)}
|
||||||
|
accessibilityState={{ selected: active }}
|
||||||
>
|
>
|
||||||
{active && <Check size={12} color={c.textInverse} />}
|
{active && <Check size={12} color={c.textInverse} />}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -40,11 +40,12 @@ const CALENDAR_PRESETS: Record<RolePreset, CalendarRights> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The webmail's sharing presets (same rights, same names).
|
||||||
const PRESET_LABEL_KEYS: Record<RolePreset, [string, string]> = {
|
const PRESET_LABEL_KEYS: Record<RolePreset, [string, string]> = {
|
||||||
freeBusy: ['calendar.share.role_free_busy', 'Free/busy'],
|
freeBusy: ['sharing.preset.freeBusy', 'Free/busy only'],
|
||||||
read: ['calendar.share.role_viewer', 'Viewer'],
|
read: ['sharing.preset.read', 'Read only'],
|
||||||
readWrite: ['calendar.share.role_editor', 'Editor'],
|
readWrite: ['sharing.preset.readWrite', 'Read & write'],
|
||||||
manager: ['calendar.share.role_manager', 'Manager'],
|
manager: ['sharing.preset.manager', 'Manager'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const PRESET_ORDER: RolePreset[] = ['freeBusy', 'read', 'readWrite', 'manager'];
|
const PRESET_ORDER: RolePreset[] = ['freeBusy', 'read', 'readWrite', 'manager'];
|
||||||
@@ -159,7 +160,7 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
|
|||||||
<View style={styles.titleRow}>
|
<View style={styles.titleRow}>
|
||||||
<Users size={18} color={c.textMuted} />
|
<Users size={18} color={c.textMuted} />
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
{t('calendar.share.title', 'Share')} “{calendar.name}”
|
{t('sharing.title', 'Share "{name}"', { name: calendar.name })}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -180,6 +181,8 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
|
|||||||
onPress={() => void applyShare(principalId, CALENDAR_PRESETS[p])}
|
onPress={() => void applyShare(principalId, CALENDAR_PRESETS[p])}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
style={[styles.chip, preset === p && styles.chipActive]}
|
style={[styles.chip, preset === p && styles.chipActive]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ selected: preset === p, disabled: busy }}
|
||||||
>
|
>
|
||||||
<Text style={[styles.chipText, preset === p && styles.chipTextActive]}>
|
<Text style={[styles.chipText, preset === p && styles.chipTextActive]}>
|
||||||
{t(PRESET_LABEL_KEYS[p][0], PRESET_LABEL_KEYS[p][1])}
|
{t(PRESET_LABEL_KEYS[p][0], PRESET_LABEL_KEYS[p][1])}
|
||||||
@@ -187,13 +190,15 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
{preset === 'custom' ? (
|
{preset === 'custom' ? (
|
||||||
<Text style={styles.customLabel}>{t('calendar.share.role_custom', 'Custom')}</Text>
|
<Text style={styles.customLabel}>{t('sharing.preset.custom', 'Custom')}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => void applyShare(principalId, null)}
|
onPress={() => void applyShare(principalId, null)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={styles.removeBtn}
|
style={styles.removeBtn}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('sharing.remove', 'Remove access')}
|
||||||
>
|
>
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<ActivityIndicator size="small" color={c.textMuted} />
|
<ActivityIndicator size="small" color={c.textMuted} />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type { Calendar } from '../../api/types';
|
|||||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useAnimDuration } from '../../theme/dynamic';
|
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 { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
|
||||||
import { isWritableCalendar } from '../../lib/calendar-editability';
|
import { isWritableCalendar } from '../../lib/calendar-editability';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
@@ -152,7 +152,13 @@ export function CalendarSidebarDrawer({
|
|||||||
>
|
>
|
||||||
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
|
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
style={styles.headerClose}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('common.close', 'Close')}
|
||||||
|
>
|
||||||
<X size={20} color={c.text} />
|
<X size={20} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.headerTitle}>{t('calendar.my_calendars', 'Calendars')}</Text>
|
<Text style={styles.headerTitle}>{t('calendar.my_calendars', 'Calendars')}</Text>
|
||||||
@@ -274,6 +280,8 @@ function Section({
|
|||||||
styles.row,
|
styles.row,
|
||||||
pressed && styles.rowPressed,
|
pressed && styles.rowPressed,
|
||||||
]}
|
]}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityState={{ checked: visible }}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -328,6 +336,9 @@ function Section({
|
|||||||
{ backgroundColor: color },
|
{ backgroundColor: color },
|
||||||
active && styles.paletteSwatchActive,
|
active && styles.paletteSwatchActive,
|
||||||
]}
|
]}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityLabel={calendarColorName(color, t)}
|
||||||
|
accessibilityState={{ selected: active }}
|
||||||
>
|
>
|
||||||
{active && <Check size={12} color={c.textInverse} />}
|
{active && <Check size={12} color={c.textInverse} />}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ import { getEventEditability } from '../../lib/calendar-editability';
|
|||||||
import { useContactNameResolver } from '../../lib/contact-name-resolver';
|
import { useContactNameResolver } from '../../lib/contact-name-resolver';
|
||||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
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';
|
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)}`;
|
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: TranslateFn, locale: Locale): string | null {
|
||||||
|
|
||||||
function recurrenceLabel(event: CalendarEvent, t: Translate, locale: Locale): string | null {
|
|
||||||
const rule = event.recurrenceRules?.[0];
|
const rule = event.recurrenceRules?.[0];
|
||||||
if (!rule) return null;
|
if (!rule) return null;
|
||||||
const freq = rule.frequency.toLowerCase();
|
const freq = rule.frequency.toLowerCase();
|
||||||
const unitKey: Record<string, [string, string]> = {
|
const label = recurrenceIntervalLabel(freq, rule.interval ?? 1, t)
|
||||||
daily: ['calendar.recurrence.every_n_days', 'Every {count} days'],
|
?? t(`calendar.recurrence.${freq}`, rule.frequency);
|
||||||
weekly: ['calendar.recurrence.every_n_weeks', 'Every {count} weeks'],
|
const end = recurrenceEndLabel(rule, t, locale);
|
||||||
monthly: ['calendar.recurrence.every_n_months', 'Every {count} months'],
|
return end ? `${label} · ${end}` : label;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventDetailSheet({
|
export function EventDetailSheet({
|
||||||
@@ -257,7 +243,13 @@ export function EventDetailSheet({
|
|||||||
</Text>
|
</Text>
|
||||||
{calendar && <Text style={styles.subtitle}>{calendar.name}</Text>}
|
{calendar && <Text style={styles.subtitle}>{calendar.name}</Text>}
|
||||||
</View>
|
</View>
|
||||||
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
style={styles.closeBtn}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('common.close', 'Close')}
|
||||||
|
>
|
||||||
<X size={20} color={c.textMuted} />
|
<X size={20} color={c.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -362,7 +354,7 @@ export function EventDetailSheet({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.participantName} numberOfLines={1}>
|
<Text style={styles.participantName} numberOfLines={1}>
|
||||||
{p.name || p.email || 'Unknown'}
|
{p.name || p.email || t('common.unknown', 'Unknown')}
|
||||||
{p.isOrganizer ? (
|
{p.isOrganizer ? (
|
||||||
<Text style={styles.participantOrganizer}>
|
<Text style={styles.participantOrganizer}>
|
||||||
{' '}({t('calendar.participants.organizer', 'Organizer').toLowerCase()})
|
{' '}({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) {
|
switch (status) {
|
||||||
case 'accepted':
|
case 'accepted':
|
||||||
return t('calendar.participants.accepted', 'Accepted');
|
return t('calendar.participants.accepted', 'Accepted');
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ export function EventModal({
|
|||||||
const recurrenceOption = RECURRENCE_OPTIONS.find((o) => o.value === recurrence);
|
const recurrenceOption = RECURRENCE_OPTIONS.find((o) => o.value === recurrence);
|
||||||
const recurrenceLabel =
|
const recurrenceLabel =
|
||||||
recurrence === 'custom'
|
recurrence === 'custom'
|
||||||
? (customRule && buildRecurrenceSummary(customRule)) || t('calendar.recurrence.custom', 'Custom…')
|
? (customRule && buildRecurrenceSummary(customRule, t, locale)) || t('calendar.recurrence.custom', 'Custom…')
|
||||||
: recurrenceOption
|
: recurrenceOption
|
||||||
? t(recurrenceOption.key, recurrenceOption.fallback)
|
? t(recurrenceOption.key, recurrenceOption.fallback)
|
||||||
: t('calendar.recurrence.none', 'Does not repeat');
|
: t('calendar.recurrence.none', 'Does not repeat');
|
||||||
@@ -416,7 +416,13 @@ export function EventModal({
|
|||||||
>
|
>
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={onClose} hitSlop={8} style={styles.headerBtn}>
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.headerBtn}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('common.close', 'Close')}
|
||||||
|
>
|
||||||
<X size={20} color={c.text} />
|
<X size={20} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.headerTitle}>
|
<Text style={styles.headerTitle}>
|
||||||
@@ -501,6 +507,7 @@ export function EventModal({
|
|||||||
<Switch
|
<Switch
|
||||||
value={allDay}
|
value={allDay}
|
||||||
onValueChange={setAllDay}
|
onValueChange={setAllDay}
|
||||||
|
accessibilityLabel={t('calendar.form.all_day_event', 'All-day event')}
|
||||||
thumbColor={allDay ? c.primary : c.textMuted}
|
thumbColor={allDay ? c.primary : c.textMuted}
|
||||||
trackColor={{ false: c.surface, true: c.primaryBg }}
|
trackColor={{ false: c.surface, true: c.primaryBg }}
|
||||||
/>
|
/>
|
||||||
@@ -608,6 +615,8 @@ export function EventModal({
|
|||||||
onPress={() => removeReminder(r.minutesBefore)}
|
onPress={() => removeReminder(r.minutesBefore)}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={styles.reminderRemove}
|
style={styles.reminderRemove}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('calendar.alerts.remove', 'Remove reminder')}
|
||||||
>
|
>
|
||||||
<X size={16} color={c.textMuted} />
|
<X size={16} color={c.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -739,6 +748,7 @@ export function EventModal({
|
|||||||
<Switch
|
<Switch
|
||||||
value={sendInvitations}
|
value={sendInvitations}
|
||||||
onValueChange={setSendInvitations}
|
onValueChange={setSendInvitations}
|
||||||
|
accessibilityLabel={t('calendar.participants.send_invitations', 'Send invitations to participants')}
|
||||||
thumbColor={sendInvitations ? c.primary : c.textMuted}
|
thumbColor={sendInvitations ? c.primary : c.textMuted}
|
||||||
trackColor={{ false: c.surface, true: c.primaryBg }}
|
trackColor={{ false: c.surface, true: c.primaryBg }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -192,7 +192,13 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
|
|||||||
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
|
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>{t('calendar.import.title', 'Import Calendar')}</Text>
|
<Text style={styles.title}>{t('calendar.import.title', 'Import Calendar')}</Text>
|
||||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.close}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('common.close', 'Close')}
|
||||||
|
>
|
||||||
<X size={20} color={c.text} />
|
<X size={20} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { formatDistanceToNow } from 'date-fns';
|
|||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
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 {
|
import {
|
||||||
DEFAULT_REFRESH_INTERVAL_MINUTES,
|
DEFAULT_REFRESH_INTERVAL_MINUTES,
|
||||||
selectAccountSubscriptions,
|
selectAccountSubscriptions,
|
||||||
@@ -17,11 +18,12 @@ import {
|
|||||||
} from '../../stores/calendar-subscriptions-store';
|
} from '../../stores/calendar-subscriptions-store';
|
||||||
import { jmapClient } from '../../api/jmap-client';
|
import { jmapClient } from '../../api/jmap-client';
|
||||||
|
|
||||||
const INTERVAL_OPTIONS: { minutes: number; label: string }[] = [
|
// The webmail's refresh intervals and their labels.
|
||||||
{ minutes: 15, label: '15 min' },
|
const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [
|
||||||
{ minutes: 60, label: '1 h' },
|
{ minutes: 15, key: 'calendar.subscription.interval_15', fallback: 'Every 15 minutes' },
|
||||||
{ minutes: 360, label: '6 h' },
|
{ minutes: 60, key: 'calendar.subscription.interval_60', fallback: 'Every hour' },
|
||||||
{ minutes: 1440, label: '1 day' },
|
{ minutes: 360, key: 'calendar.subscription.interval_360', fallback: 'Every 6 hours' },
|
||||||
|
{ minutes: 1440, key: 'calendar.subscription.interval_1440', fallback: 'Every day' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -33,6 +35,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
|||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const { locale, t } = useCalendarLocale();
|
||||||
const allSubscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
const allSubscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
||||||
// Only the signed-in account's feeds: a sub created under another account
|
// 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.
|
// 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 n = name.trim();
|
||||||
const u = url.trim();
|
const u = url.trim();
|
||||||
if (!n || !u) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!/^(https?|webcals?):\/\//i.test(u)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
@@ -115,7 +118,13 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
}
|
}
|
||||||
@@ -130,26 +139,38 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
|||||||
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
|
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Rss size={20} color={c.text} />
|
<Rss size={20} color={c.text} />
|
||||||
<Text style={styles.title}>Calendar subscriptions</Text>
|
<Text style={styles.title}>{t('calendar.subscription.section_title', 'iCal Subscriptions')}</Text>
|
||||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.close}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('common.close', 'Close')}
|
||||||
|
>
|
||||||
<X size={20} color={c.text} />
|
<X size={20} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={styles.scroll}>
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={styles.scroll}>
|
||||||
<Text style={styles.sectionLabel}>{editingId ? 'Edit subscription' : 'Add a feed'}</Text>
|
<Text style={styles.sectionLabel}>
|
||||||
|
{editingId
|
||||||
|
? t('calendar.subscription.edit_title', 'Edit Subscription')
|
||||||
|
: t('calendar.subscription.add_feed', 'Add a feed')}
|
||||||
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={name}
|
value={name}
|
||||||
onChangeText={setName}
|
onChangeText={setName}
|
||||||
placeholder="Name (e.g. Holidays)"
|
placeholder={t('calendar.subscription.name_placeholder', 'e.g. Public Holidays')}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
|
accessibilityLabel={t('calendar.subscription.name_label', 'Calendar name')}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={url}
|
value={url}
|
||||||
onChangeText={setUrl}
|
onChangeText={setUrl}
|
||||||
placeholder="https://… or webcal://…"
|
placeholder={t('calendar.subscription.url_placeholder', 'https://example.com/calendar.ics or webcal://...')}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
|
accessibilityLabel={t('calendar.subscription.url_label', 'Calendar URL')}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
keyboardType="url"
|
keyboardType="url"
|
||||||
@@ -165,10 +186,13 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
|||||||
{ backgroundColor: col },
|
{ backgroundColor: col },
|
||||||
color === col && styles.colorDotActive,
|
color === col && styles.colorDotActive,
|
||||||
]}
|
]}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityLabel={calendarColorName(col, t)}
|
||||||
|
accessibilityState={{ selected: color === col }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.fieldLabel}>Refresh every</Text>
|
<Text style={styles.fieldLabel}>{t('calendar.subscription.refresh_interval', 'Refresh interval')}</Text>
|
||||||
<View style={styles.intervalRow}>
|
<View style={styles.intervalRow}>
|
||||||
{INTERVAL_OPTIONS.map((opt) => {
|
{INTERVAL_OPTIONS.map((opt) => {
|
||||||
const active = interval === opt.minutes;
|
const active = interval === opt.minutes;
|
||||||
@@ -177,9 +201,11 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
|||||||
key={opt.minutes}
|
key={opt.minutes}
|
||||||
onPress={() => setInterval(opt.minutes)}
|
onPress={() => setInterval(opt.minutes)}
|
||||||
style={[styles.intervalChip, active && styles.intervalChipActive]}
|
style={[styles.intervalChip, active && styles.intervalChipActive]}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityState={{ selected: active }}
|
||||||
>
|
>
|
||||||
<Text style={[styles.intervalChipText, active && styles.intervalChipTextActive]}>
|
<Text style={[styles.intervalChipText, active && styles.intervalChipTextActive]}>
|
||||||
{opt.label}
|
{t(opt.key, opt.fallback)}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -194,7 +220,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
|||||||
<View style={styles.formActions}>
|
<View style={styles.formActions}>
|
||||||
{editingId && (
|
{editingId && (
|
||||||
<Button variant="outline" onPress={resetForm} disabled={adding}>
|
<Button variant="outline" onPress={resetForm} disabled={adding}>
|
||||||
Cancel
|
{t('common.cancel', 'Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -202,13 +228,21 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
|||||||
disabled={adding}
|
disabled={adding}
|
||||||
icon={adding ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <Plus size={16} color={c.primaryForeground} />}
|
icon={adding ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <Plus size={16} color={c.primaryForeground} />}
|
||||||
>
|
>
|
||||||
{adding ? (editingId ? 'Saving…' : 'Subscribing…') : editingId ? 'Save changes' : 'Subscribe'}
|
{adding
|
||||||
|
? (editingId
|
||||||
|
? t('calendar.subscription.saving', 'Saving...')
|
||||||
|
: t('calendar.subscription.subscribing', 'Subscribing...'))
|
||||||
|
: editingId
|
||||||
|
? t('calendar.subscription.save', 'Save changes')
|
||||||
|
: t('calendar.subscription.subscribe', 'Subscribe')}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{subscriptions.length > 0 && (
|
{subscriptions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Text style={[styles.sectionLabel, { marginTop: spacing.xl }]}>Your subscriptions</Text>
|
<Text style={[styles.sectionLabel, { marginTop: spacing.xl }]}>
|
||||||
|
{t('calendar.subscription.your_subscriptions', 'Your subscriptions')}
|
||||||
|
</Text>
|
||||||
{subscriptions.map((sub) => {
|
{subscriptions.map((sub) => {
|
||||||
const busy = !!syncing[sub.id];
|
const busy = !!syncing[sub.id];
|
||||||
return (
|
return (
|
||||||
@@ -220,8 +254,10 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
|||||||
{sub.lastError
|
{sub.lastError
|
||||||
? sub.lastError
|
? sub.lastError
|
||||||
: sub.lastSyncAt
|
: sub.lastSyncAt
|
||||||
? `Synced ${formatDistanceToNow(sub.lastSyncAt, { addSuffix: true })}`
|
? t('calendar.subscription.last_refreshed', 'Last updated: {time}', {
|
||||||
: 'Not synced yet'}
|
time: formatDistanceToNow(sub.lastSyncAt, { addSuffix: true, locale }),
|
||||||
|
})
|
||||||
|
: t('calendar.subscription.not_synced', 'Not synced yet')}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -229,6 +265,8 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
|||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
style={styles.subBtn}
|
style={styles.subBtn}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('calendar.subscription.refresh', 'Refresh now')}
|
||||||
>
|
>
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<ActivityIndicator size="small" color={c.textMuted} />
|
<ActivityIndicator size="small" color={c.textMuted} />
|
||||||
@@ -240,6 +278,8 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
|||||||
onPress={() => { void removeSubscription(sub.id); }}
|
onPress={() => { void removeSubscription(sub.id); }}
|
||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
style={styles.subBtn}
|
style={styles.subBtn}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('calendar.subscription.unsubscribe', 'Unsubscribe')}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} color={c.error} />
|
<Trash2 size={16} color={c.error} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -93,7 +93,12 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
|
|||||||
<Text style={styles.chipText} numberOfLines={1}>
|
<Text style={styles.chipText} numberOfLines={1}>
|
||||||
{a.name || a.email}
|
{a.name || a.email}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable onPress={() => onRemove(a.email)} hitSlop={6}>
|
<Pressable
|
||||||
|
onPress={() => onRemove(a.email)}
|
||||||
|
hitSlop={6}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('calendar.participants.remove', 'Remove')}
|
||||||
|
>
|
||||||
<X size={12} color={c.textMuted} />
|
<X size={12} color={c.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import type { RecurrenceRule } from '../../api/types';
|
|||||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { Button } from '..';
|
import { Button } from '..';
|
||||||
|
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||||
import {
|
import {
|
||||||
EDITOR_FREQUENCIES,
|
EDITOR_FREQUENCIES,
|
||||||
UNIT_LABELS,
|
UNIT_LABEL_KEYS,
|
||||||
WEEKDAYS,
|
WEEKDAYS,
|
||||||
buildRecurrenceSummary,
|
buildRecurrenceSummary,
|
||||||
buildRuleFromEditorValue,
|
buildRuleFromEditorValue,
|
||||||
@@ -80,11 +81,13 @@ function NumberInput({
|
|||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
onChange,
|
onChange,
|
||||||
|
accessibilityLabel,
|
||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number;
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
|
accessibilityLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
@@ -102,6 +105,7 @@ function NumberInput({
|
|||||||
}}
|
}}
|
||||||
keyboardType="number-pad"
|
keyboardType="number-pad"
|
||||||
style={styles.numberInput}
|
style={styles.numberInput}
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -109,6 +113,7 @@ function NumberInput({
|
|||||||
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
|
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const { locale, t } = useCalendarLocale();
|
||||||
|
|
||||||
const [value, setValue] = React.useState<RecurrenceEditorValue>(() =>
|
const [value, setValue] = React.useState<RecurrenceEditorValue>(() =>
|
||||||
editorValueFromRule(rule, eventStart),
|
editorValueFromRule(rule, eventStart),
|
||||||
@@ -130,22 +135,23 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
|||||||
};
|
};
|
||||||
|
|
||||||
const builtRule = buildRuleFromEditorValue(value, eventStart);
|
const builtRule = buildRuleFromEditorValue(value, eventStart);
|
||||||
const summary = buildRecurrenceSummary(builtRule);
|
const summary = buildRecurrenceSummary(builtRule, t, locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Text style={styles.rowLabel}>Every</Text>
|
<Text style={styles.rowLabel}>{t('calendar.recurrence.editor_every', 'Every')}</Text>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={value.interval}
|
value={value.interval}
|
||||||
min={1}
|
min={1}
|
||||||
max={999}
|
max={999}
|
||||||
onChange={(interval) => update({ interval })}
|
onChange={(interval) => update({ interval })}
|
||||||
|
accessibilityLabel={t('calendar.recurrence.editor_every', 'Every')}
|
||||||
/>
|
/>
|
||||||
<InlineSelect<EditorFrequency>
|
<InlineSelect<EditorFrequency>
|
||||||
flex
|
flex
|
||||||
value={value.frequency}
|
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 })}
|
onChange={(frequency) => update({ frequency })}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -159,9 +165,12 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
|||||||
key={day}
|
key={day}
|
||||||
onPress={() => toggleWeekDay(day)}
|
onPress={() => toggleWeekDay(day)}
|
||||||
style={[styles.weekdayChip, active && styles.weekdayChipActive]}
|
style={[styles.weekdayChip, active && styles.weekdayChipActive]}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityLabel={weekdayName(day, 'long', locale)}
|
||||||
|
accessibilityState={{ checked: active }}
|
||||||
>
|
>
|
||||||
<Text style={[styles.weekdayChipText, active && styles.weekdayChipTextActive]}>
|
<Text style={[styles.weekdayChipText, active && styles.weekdayChipTextActive]}>
|
||||||
{weekdayName(day, 'short')}
|
{weekdayName(day, 'short', locale)}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -171,13 +180,13 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
|||||||
|
|
||||||
{value.frequency === 'yearly' && (
|
{value.frequency === 'yearly' && (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Text style={styles.rowLabel}>In</Text>
|
<Text style={styles.rowLabel}>{capitalize(t('calendar.recurrence.editor_in', 'in'))}</Text>
|
||||||
<InlineSelect<number>
|
<InlineSelect<number>
|
||||||
flex
|
flex
|
||||||
value={value.month}
|
value={value.month}
|
||||||
options={Array.from({ length: 12 }, (_, i) => i + 1).map((m) => ({
|
options={Array.from({ length: 12 }, (_, i) => i + 1).map((m) => ({
|
||||||
value: m,
|
value: m,
|
||||||
label: capitalize(monthName(m)),
|
label: capitalize(monthName(m, locale)),
|
||||||
}))}
|
}))}
|
||||||
onChange={(month) => update({ month })}
|
onChange={(month) => update({ month })}
|
||||||
/>
|
/>
|
||||||
@@ -189,8 +198,8 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
|||||||
<InlineSelect<MonthlyMode>
|
<InlineSelect<MonthlyMode>
|
||||||
value={value.monthlyMode}
|
value={value.monthlyMode}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'day', label: 'On day' },
|
{ value: 'day', label: capitalize(t('calendar.recurrence.editor_on_day', 'on day')) },
|
||||||
{ value: 'nth', label: 'On the' },
|
{ value: 'nth', label: capitalize(t('calendar.recurrence.editor_on_the', 'on the')) },
|
||||||
]}
|
]}
|
||||||
onChange={(monthlyMode) => update({ monthlyMode })}
|
onChange={(monthlyMode) => update({ monthlyMode })}
|
||||||
/>
|
/>
|
||||||
@@ -200,19 +209,20 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
|||||||
min={1}
|
min={1}
|
||||||
max={31}
|
max={31}
|
||||||
onChange={(monthDay) => update({ monthDay })}
|
onChange={(monthDay) => update({ monthDay })}
|
||||||
|
accessibilityLabel={t('calendar.recurrence.editor_on_day', 'on day')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<InlineSelect<number>
|
<InlineSelect<number>
|
||||||
flex
|
flex
|
||||||
value={value.nth}
|
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 })}
|
onChange={(nth) => update({ nth })}
|
||||||
/>
|
/>
|
||||||
<InlineSelect<string>
|
<InlineSelect<string>
|
||||||
flex
|
flex
|
||||||
value={value.nthDay}
|
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 })}
|
onChange={(nthDay) => update({ nthDay })}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -221,21 +231,21 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Text style={styles.rowLabel}>Ends</Text>
|
<Text style={styles.rowLabel}>{t('calendar.recurrence.editor_ends', 'Ends')}</Text>
|
||||||
<InlineSelect<EndsMode>
|
<InlineSelect<EndsMode>
|
||||||
flex={value.endsMode === 'never'}
|
flex={value.endsMode === 'never'}
|
||||||
value={value.endsMode}
|
value={value.endsMode}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'never', label: 'Never' },
|
{ value: 'never', label: t('calendar.recurrence.editor_never', 'Never') },
|
||||||
{ value: 'on', label: 'On date' },
|
{ value: 'on', label: t('calendar.recurrence.editor_ends_on', 'On') },
|
||||||
{ value: 'after', label: 'After' },
|
{ value: 'after', label: t('calendar.recurrence.editor_ends_after', 'After') },
|
||||||
]}
|
]}
|
||||||
onChange={(endsMode) => update({ endsMode })}
|
onChange={(endsMode) => update({ endsMode })}
|
||||||
/>
|
/>
|
||||||
{value.endsMode === 'on' && (
|
{value.endsMode === 'on' && (
|
||||||
<Pressable style={[styles.selectButton, styles.selectFlex]} onPress={() => setShowUntilPicker(true)}>
|
<Pressable style={[styles.selectButton, styles.selectFlex]} onPress={() => setShowUntilPicker(true)}>
|
||||||
<Text style={styles.selectText}>
|
<Text style={styles.selectText}>
|
||||||
{format(parseISO(value.untilDate), 'MMM d, yyyy')}
|
{format(parseISO(value.untilDate), 'MMM d, yyyy', { locale })}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
@@ -246,8 +256,11 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
|||||||
min={1}
|
min={1}
|
||||||
max={999}
|
max={999}
|
||||||
onChange={(count) => update({ count })}
|
onChange={(count) => update({ count })}
|
||||||
|
accessibilityLabel={t('calendar.recurrence.editor_ends_after', 'After')}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.muted}>occurrences</Text>
|
<Text style={styles.muted}>
|
||||||
|
{t('calendar.recurrence.editor_occurrences_count', '{count, plural, one {occurrence} other {occurrences}}', { count: value.count })}
|
||||||
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -258,10 +271,10 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
|||||||
</Text>
|
</Text>
|
||||||
<View style={styles.footerButtons}>
|
<View style={styles.footerButtons}>
|
||||||
<Button variant="outline" size="sm" onPress={onCancel}>
|
<Button variant="outline" size="sm" onPress={onCancel}>
|
||||||
Cancel
|
{t('common.cancel', 'Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="default" size="sm" onPress={() => onSave(builtRule)}>
|
<Button variant="default" size="sm" onPress={() => onSave(builtRule)}>
|
||||||
Done
|
{t('common.done', 'Done')}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -241,7 +241,13 @@ export function TasksSheet({
|
|||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<ListChecks size={20} color={c.text} />
|
<ListChecks size={20} color={c.text} />
|
||||||
<Text style={styles.headerTitle}>{t('calendar.tasks.label', 'Tasks')}</Text>
|
<Text style={styles.headerTitle}>{t('calendar.tasks.label', 'Tasks')}</Text>
|
||||||
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
style={styles.closeBtn}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('common.close', 'Close')}
|
||||||
|
>
|
||||||
<X size={20} color={c.textMuted} />
|
<X size={20} color={c.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -262,6 +268,8 @@ export function TasksSheet({
|
|||||||
onPress={() => { void handleSave(); }}
|
onPress={() => { void handleSave(); }}
|
||||||
disabled={!editor.title.trim() || !editor.calendarId || saving}
|
disabled={!editor.title.trim() || !editor.calendarId || saving}
|
||||||
style={[styles.addBtn, (!editor.title.trim() || saving) && styles.addBtnDisabled]}
|
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 ? <Check size={18} color={c.primaryForeground} /> : <Plus size={18} color={c.primaryForeground} />}
|
{editor.id ? <Check size={18} color={c.primaryForeground} /> : <Plus size={18} color={c.primaryForeground} />}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -290,6 +298,8 @@ export function TasksSheet({
|
|||||||
<Pressable
|
<Pressable
|
||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
onPress={() => setEditor((e) => ({ ...e, due: null, withTime: false }))}
|
onPress={() => setEditor((e) => ({ ...e, due: null, withTime: false }))}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('calendar.tasks.clear_due_date', 'Clear due date')}
|
||||||
>
|
>
|
||||||
<X size={12} color={c.textMuted} />
|
<X size={12} color={c.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -383,6 +393,9 @@ export function TasksSheet({
|
|||||||
onPress={() => { void onToggle(task.id); }}
|
onPress={() => { void onToggle(task.id); }}
|
||||||
style={[styles.checkbox, completed && styles.checkboxChecked]}
|
style={[styles.checkbox, completed && styles.checkboxChecked]}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityLabel={task.title || t('calendar.tasks.no_title', '(No title)')}
|
||||||
|
accessibilityState={{ checked: completed }}
|
||||||
>
|
>
|
||||||
{completed && <Check size={14} color={c.primaryForeground} />}
|
{completed && <Check size={14} color={c.primaryForeground} />}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -407,7 +420,13 @@ export function TasksSheet({
|
|||||||
<Text style={[styles.taskDue, overdue && styles.taskDueOverdue]}>{due}</Text>
|
<Text style={[styles.taskDue, overdue && styles.taskDueOverdue]}>{due}</Text>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable onPress={() => { void onDelete(task.id); }} hitSlop={8} style={styles.taskDelete}>
|
<Pressable
|
||||||
|
onPress={() => { void onDelete(task.id); }}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.taskDelete}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('calendar.tasks.delete', 'Delete')}
|
||||||
|
>
|
||||||
<Trash2 size={16} color={c.textMuted} />
|
<Trash2 size={16} color={c.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -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 type { Locale } from 'date-fns';
|
||||||
import { enUS } from 'date-fns/locale/en-US';
|
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';
|
import type { LocaleCode } from '../i18n';
|
||||||
|
|
||||||
// date-fns locale for each UI language so month/day names, "EEE, MMM d"
|
// 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. */
|
/** `{ 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 code = useLocaleStore((s) => s.locale);
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
return { locale: getDateFnsLocale(code), t };
|
return { locale: getDateFnsLocale(code), t };
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import type { Calendar, CalendarEvent } from '../api/types';
|
import type { Calendar, CalendarEvent } from '../api/types';
|
||||||
import { colors } from '../theme/tokens';
|
import { colors } from '../theme/tokens';
|
||||||
|
import type { TranslateFn } from '../stores/locale-store';
|
||||||
import { zonedWallTimeToUtc } from './recurrence-expansion';
|
import { zonedWallTimeToUtc } from './recurrence-expansion';
|
||||||
import { getEffectiveTimeZone, localDateTimeToInstant, toDisplayDate } from './calendar-timezone';
|
import { getEffectiveTimeZone, localDateTimeToInstant, toDisplayDate } from './calendar-timezone';
|
||||||
|
|
||||||
@@ -500,6 +501,27 @@ const CALENDAR_PALETTE = [
|
|||||||
|
|
||||||
export const CALENDAR_COLOR_PALETTE = 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 {
|
function hashString(value: string): number {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < value.length; i++) {
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
|||||||
+78
-54
@@ -4,8 +4,9 @@
|
|||||||
* construction, and the human-readable summary).
|
* 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 { RecurrenceRule } from '../api/types';
|
||||||
|
import type { TranslateFn } from '../stores/locale-store';
|
||||||
|
|
||||||
export type EditorFrequency = 'daily' | 'weekly' | 'monthly' | 'yearly';
|
export type EditorFrequency = 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||||
export type MonthlyMode = 'day' | 'nth';
|
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 };
|
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 INDEX_TO_DAY = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'];
|
||||||
|
|
||||||
export const UNIT_LABELS: Record<EditorFrequency, string> = {
|
// The editor's unit picker (webmail UNIT_LABEL_KEYS): key and English text.
|
||||||
daily: 'days',
|
export const UNIT_LABEL_KEYS: Record<EditorFrequency, [string, string]> = {
|
||||||
weekly: 'weeks',
|
daily: ['calendar.recurrence.editor_unit_days', 'day(s)'],
|
||||||
monthly: 'months',
|
weekly: ['calendar.recurrence.editor_unit_weeks', 'week(s)'],
|
||||||
yearly: 'years',
|
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.
|
// 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);
|
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 {
|
export function monthName(month: number, locale?: Locale): string {
|
||||||
return format(new Date(2024, month - 1, 1), 'LLLL');
|
return format(new Date(2024, month - 1, 1), 'LLLL', { locale });
|
||||||
}
|
}
|
||||||
|
|
||||||
const NTH_LABELS: Record<number, string> = {
|
export function nthLabel(nth: number, t: TranslateFn): string {
|
||||||
1: 'first',
|
switch (nth) {
|
||||||
2: 'second',
|
case 1: return t('calendar.recurrence.nth_1', 'first');
|
||||||
3: 'third',
|
case 2: return t('calendar.recurrence.nth_2', 'second');
|
||||||
4: 'fourth',
|
case 3: return t('calendar.recurrence.nth_3', 'third');
|
||||||
[-1]: 'last',
|
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 {
|
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.
|
* 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.).
|
* Returns null for frequencies the UI cannot describe (hourly etc.).
|
||||||
*/
|
*/
|
||||||
export function buildRecurrenceSummary(rule: RecurrenceRule): string | null {
|
export function buildRecurrenceSummary(rule: RecurrenceRule, t: TranslateFn, locale?: Locale): string | null {
|
||||||
const interval = rule.interval || 1;
|
const base = recurrenceIntervalLabel(rule.frequency, rule.interval || 1, t);
|
||||||
let base: string;
|
if (!base) return null;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = [base];
|
const parts = [base];
|
||||||
|
|
||||||
@@ -115,34 +143,30 @@ export function buildRecurrenceSummary(rule: RecurrenceRule): string | null {
|
|||||||
const days = rule.byDay
|
const days = rule.byDay
|
||||||
.filter((d) => WEEKDAYS.includes(d.day))
|
.filter((d) => WEEKDAYS.includes(d.day))
|
||||||
.sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.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(', ');
|
.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 === 'monthly' || rule.frequency === 'yearly') {
|
||||||
if (rule.frequency === 'yearly' && rule.byMonth?.length) {
|
if (rule.frequency === 'yearly' && rule.byMonth?.length) {
|
||||||
const m = parseInt(rule.byMonth[0], 10);
|
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);
|
const nthDay = getNthDay(rule);
|
||||||
if (nthDay) {
|
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) {
|
} 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(' ');
|
const summary = parts.join(' ');
|
||||||
if (rule.count) {
|
const end = recurrenceEndLabel(rule, t, locale);
|
||||||
summary += ` · ${rule.count} ${rule.count === 1 ? 'occurrence' : 'occurrences'}`;
|
return end ? `${summary} · ${end}` : summary;
|
||||||
} else if (rule.until) {
|
|
||||||
const d = new Date(rule.until);
|
|
||||||
if (!isNaN(d.getTime())) {
|
|
||||||
summary += ` · until ${format(d, 'MMM d, yyyy')}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return summary;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecurrenceEditorValue {
|
export interface RecurrenceEditorValue {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { jmapClient } from '../api/jmap-client';
|
import { jmapClient } from '../api/jmap-client';
|
||||||
import { uploadBytes } from '../api/blob';
|
import { uploadBytes } from '../api/blob';
|
||||||
import { useCalendarStore } from './calendar-store';
|
import { useCalendarStore } from './calendar-store';
|
||||||
|
import { t } from './locale-store';
|
||||||
|
|
||||||
export interface CalendarSubscription {
|
export interface CalendarSubscription {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -100,13 +101,16 @@ function base64(input: string): string {
|
|||||||
async function fetchAndParseFeed(url: string): Promise<Partial<CalendarEvent>[]> {
|
async function fetchAndParseFeed(url: string): Promise<Partial<CalendarEvent>[]> {
|
||||||
const { url: feedUrl, headers } = normalizeFeedUrl(url);
|
const { url: feedUrl, headers } = normalizeFeedUrl(url);
|
||||||
const res = await fetch(feedUrl, { headers });
|
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);
|
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();
|
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)) {
|
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 bytes = new TextEncoder().encode(text);
|
||||||
const { blobId } = await uploadBytes(bytes, 'text/calendar');
|
const { blobId } = await uploadBytes(bytes, 'text/calendar');
|
||||||
@@ -177,7 +181,7 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
|
|||||||
// best-effort
|
// best-effort
|
||||||
}
|
}
|
||||||
await useCalendarStore.getState().fetchCalendars();
|
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 {
|
} finally {
|
||||||
set({ syncing: { ...get().syncing, [sub.id]: false } });
|
set({ syncing: { ...get().syncing, [sub.id]: false } });
|
||||||
}
|
}
|
||||||
@@ -234,7 +238,7 @@ export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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({
|
set({
|
||||||
subscriptions: get().subscriptions.map((s) =>
|
subscriptions: get().subscriptions.map((s) =>
|
||||||
s.id === id ? { ...s, lastError: message } : s,
|
s.id === id ? { ...s, lastError: message } : s,
|
||||||
|
|||||||
Reference in New Issue
Block a user