diff --git a/docs/parity/05-calendar.md b/docs/parity/05-calendar.md index 08ef404f..17db775c 100644 --- a/docs/parity/05-calendar.md +++ b/docs/parity/05-calendar.md @@ -115,12 +115,12 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi - What RN does: `if (!participantId || participantId.includes('..')) throw` (`src/stores/calendar-store.ts:332-334`); a server-generated id like `a..b` cannot RSVP. - Fix hint: drop the `..` check. -- [ ] **Invitation banner: no trust assessment, no METHOD from Content-Type / raw ICS, no inline body-part detection** — `P2` — `partial` +- [x] **Invitation banner: no trust assessment, no METHOD from Content-Type / raw ICS, no inline body-part detection** — `P2` — `partial` — fixed in 8206893 (auth results parsed from `email.headers`) - What WEB does: `getInvitationTrustAssessment` (DMARC/DKIM/SPF + sender/organizer mismatch -> trusted/caution/warning banner), `getInvitationMethod` reads `method=` from the attachment/body Content-Type, then `extractMethodFromRawIcs`, then infers; `findCalendarAttachment` also walks `textBody`/`htmlBody` sub-parts (ref `lib/calendar-invitation.ts:341-446`, changelog 1.6.x "RSVP with trust assessment"). - What RN does: `inferInvitationMethod(parsed)` only (`src/components/email/CalendarInvitationBanner.tsx:62`; `extractMethodFromRawIcs` exists at `src/lib/calendar-invitation.ts:103-106` but is unused); `findCalendarAttachment` checks `email.attachments` only (`:139-148`); no trust UI. A spoofed invitation from an unauthenticated sender looks identical to a real one, and a REPLY/CANCEL whose ICS carries no participants is treated as `unknown` and offered "Add to calendar". - Fix hint: port `getInvitationTrustAssessment` (RN `Email` has `authenticationResults`?; if not, add to the fetched properties) and show a coloured banner + reason; read `attachment.type` params and fall back to downloading the blob text for `METHOD:`. -- [ ] **Invitation banner imports into the first writable calendar, not the default calendar, and parses against the primary account** — `P3` — `partial` +- [x] **Invitation banner imports into the first writable calendar, not the default calendar, and parses against the primary account** — `P3` — `partial` — fixed in 3fd5f10 + 8206893 - What WEB does: imports into the default calendar of the account that owns the email; shared-folder emails are parsed against the folder owner (#867) and multi-account lookups are routed by source account (#847) (changelog 1.9.0). - What RN does: `calendars.find(cal => !cal.myRights || cal.myRights.mayWrite !== false)` (`src/components/email/CalendarInvitationBanner.tsx:96`) and `parseCalendarBlob(attachment.blobId)` on `jmapClient.accountId` (`:59`, `src/api/calendar.ts:355-372`). RN shows only the active account's mailboxes, so #847/#867 are largely N/A; the "first writable" pick is wrong when a subscription calendar sorts first. - Fix hint: prefer `cal.isDefault && !cal.isShared`, exclude subscription/birthday calendars; pass `email.accountId` (if RN exposes shared mailboxes later) to `parseCalendarBlob`. diff --git a/locales/en/common.json b/locales/en/common.json index 266f0940..bc94f3c8 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2762,7 +2762,8 @@ "new_event": "New event", "new_all_day_event": "New all-day event", "new_task": "New task", - "go_to_today": "Go to today" + "go_to_today": "Go to today", + "tap_to_create": "Tap + to create one" }, "detail": { "add_note": "Add a note...", @@ -2788,7 +2789,10 @@ "save": "Save", "cancel": "Cancel", "delete_confirm": "Are you sure you want to delete this event?", - "color": "Color" + "color": "Color", + "time": "Time", + "location_placeholder": "Place or address", + "description_placeholder": "Notes, agenda…" }, "participants": { "title": "Participants", @@ -2915,7 +2919,11 @@ "hover_preview_delay_2s": "2-second delay", "hover_preview_off": "Disabled", "show_birthday_calendar": "Contact birthday calendar", - "show_birthday_calendar_desc": "Show a virtual calendar with birthdays from your contacts" + "show_birthday_calendar_desc": "Show a virtual calendar with birthdays from your contacts", + "time_zone": "Time zone", + "time_zone_desc": "Zone used for new events and for interpreting the calendar. \"Device\" follows the phone.", + "time_zone_auto": "Device time zone", + "show_week_numbers_mobile_desc": "Display week numbers in the month view." }, "days": { "monday": "Monday", @@ -3065,7 +3073,15 @@ "kind_events": "Events", "kind_tasks": "Tasks", "kind_both": "Events and tasks", - "kind_hint": "Decides which apps see this calendar when it is synced over CalDAV. It cannot be changed later." + "kind_hint": "Decides which apps see this calendar when it is synced over CalDAV. It cannot be changed later.", + "new_calendar": "New calendar", + "edit_calendar": "Edit calendar", + "description_placeholder": "Optional", + "clear_title": "Remove all events?", + "clear_description": "Every event in this calendar will be deleted. Events that also belong to another calendar are only unlinked.", + "clear_confirm": "Remove all", + "delete_title": "Delete calendar?", + "delete_description": "The calendar and all of its events will be deleted. This cannot be undone." }, "subscription": { "title": "iCal Subscription", @@ -3145,6 +3161,56 @@ "due_today": "Today", "due_tomorrow": "Tomorrow", "overdue": "Overdue" + }, + "agenda": { + "no_upcoming": "No upcoming events", + "days_clear": "The next {count} days are clear." + }, + "drawer": { + "no_calendars": "No calendars yet.", + "my_calendars": "My calendars", + "shared_with_me": "Shared with me", + "subscribed": "Subscribed" + }, + "share": { + "title": "Share", + "shared_with": "Shared with", + "add_people": "Add people", + "search_placeholder": "Search by name or email", + "no_principals": "Sharing is not available on this server.", + "no_matches": "No matches", + "role_free_busy": "Free/busy", + "role_viewer": "Viewer", + "role_editor": "Editor", + "role_manager": "Manager", + "role_custom": "Custom", + "error": "Failed to update sharing" + }, + "invitation": { + "reading": "Reading invitation…", + "title": "Calendar invitation", + "cancelled": "This event was cancelled", + "reply": "A participant replied to this invitation", + "organizer": "Organizer", + "join_video": "Join video call", + "already_in_calendar": "Already in your calendar", + "added": "Added to calendar", + "add_error": "Could not add the event", + "response_sent": "Response sent", + "response_error": "Could not send your response", + "add_to_calendar": "Add to calendar", + "no_writable_calendar": "No writable calendar available", + "accept": "Yes", + "tentative": "Maybe", + "decline": "No", + "trust_verified": "Sender verified", + "trust_sender_mismatch_unverified": "The sender does not match the organizer and the message is not authenticated.", + "trust_authentication_failed": "This message failed sender authentication (SPF/DKIM/DMARC).", + "trust_sender_mismatch": "The sender differs from the event organizer.", + "trust_authentication_missing": "The sender could not be verified." + }, + "event_modal": { + "participant_placeholder": "Add participant by email" } }, "sharing": { diff --git a/src/components/calendar/AgendaView.tsx b/src/components/calendar/AgendaView.tsx index c7e1d460..835fef68 100644 --- a/src/components/calendar/AgendaView.tsx +++ b/src/components/calendar/AgendaView.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { SectionList, StyleSheet, Text, View } from 'react-native'; -import { addDays, format, isSameDay, isToday, isTomorrow, parseISO, startOfDay } from 'date-fns'; +import { addDays, format, isSameDay, isToday, isTomorrow, parseISO, startOfDay, type Locale } from 'date-fns'; import { CalendarDays } from 'lucide-react-native'; import type { Calendar, CalendarEvent } from '../../api/types'; import { spacing, typography, type ThemePalette } from '../../theme/tokens'; @@ -12,6 +12,7 @@ import { type EventDayIndex, type TimeFormat, } from '../../lib/calendar-utils'; +import { useCalendarLocale } from '../../lib/calendar-locale'; import { EventCard } from './EventCard'; interface AgendaViewProps { @@ -30,10 +31,14 @@ interface DaySection { data: CalendarEvent[]; } -function formatDayHeader(date: Date): string { - if (isToday(date)) return 'Today'; - if (isTomorrow(date)) return 'Tomorrow'; - return format(date, 'EEEE, MMM d'); +function formatDayHeader( + date: Date, + t: (key: string, fallback?: string) => string, + locale: Locale, +): string { + if (isToday(date)) return t('calendar.events.today_header', 'Today'); + if (isTomorrow(date)) return t('calendar.events.tomorrow_header', 'Tomorrow'); + return format(date, 'EEEE, MMM d', { locale }); } export function AgendaView({ @@ -47,6 +52,7 @@ export function AgendaView({ }: AgendaViewProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const { locale, t } = useCalendarLocale(); const index = React.useMemo( () => eventsByDay ?? buildEventDayIndex(events), [eventsByDay, events], @@ -76,20 +82,22 @@ export function AgendaView({ return [...days.values()] .sort((a, b) => a.getTime() - b.getTime()) .map((day) => ({ - title: formatDayHeader(day), + title: formatDayHeader(day, t, locale), date: day, // Buckets are pre-sorted by buildEventDayIndex (all-day first, then by // start, then title), so no extra sort is needed here. data: eventsOnDayFromIndex(index, day), })); - }, [fromDate, daysAhead, index]); + }, [fromDate, daysAhead, index, t, locale]); if (sections.length === 0) { return ( - No upcoming events - The next {daysAhead} days are clear. + {t('calendar.agenda.no_upcoming', 'No upcoming events')} + + {t('calendar.agenda.days_clear', 'The next {count} days are clear.').replace('{count}', String(daysAhead))} + ); } @@ -107,7 +115,7 @@ export function AgendaView({ {section.title} - {format(section.date, 'MMM d')} + {format(section.date, 'MMM d', { locale })} ); }} @@ -119,7 +127,7 @@ export function AgendaView({ renderSectionFooter={({ section }) => section.data.length === 0 ? ( - No events + {t('calendar.events.no_events', 'No events')} ) : null } diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx index 0077b00e..d56bfa3c 100644 --- a/src/components/calendar/CalendarSidebarDrawer.tsx +++ b/src/components/calendar/CalendarSidebarDrawer.tsx @@ -21,6 +21,7 @@ import { useAnimDuration } from '../../theme/dynamic'; import { CALENDAR_COLOR_PALETTE, 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'; interface CalendarSidebarDrawerProps { visible: boolean; @@ -69,6 +70,7 @@ export function CalendarSidebarDrawer({ const overlayOpacity = React.useRef(new Animated.Value(0)).current; const openDuration = useAnimDuration(240); const closeDuration = useAnimDuration(200); + const t = useLocaleStore((s) => s.t); const [expandedId, setExpandedId] = React.useState(null); React.useEffect(() => { @@ -145,24 +147,24 @@ export function CalendarSidebarDrawer({ - Calendars + {t('calendar.my_calendars', 'Calendars')} {calendars.length === 0 && ( - No calendars yet. + {t('calendar.drawer.no_calendars', 'No calendars yet.')} )} {myCalendars.length > 0 && ( -
+
)} {sharedCalendars.length > 0 && ( -
+
)} {subscribed.length > 0 && ( -
+
)} {(onImport || onManageSubscriptions || onCreate) && ( @@ -173,7 +175,7 @@ export function CalendarSidebarDrawer({ style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]} > - New calendar + {t('calendar.management.new_calendar', 'New calendar')} )} {onManageSubscriptions && ( @@ -182,7 +184,7 @@ export function CalendarSidebarDrawer({ style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]} > - Subscriptions + {t('calendar.subscription.section_title', 'iCal Subscriptions')} )} {onImport && ( @@ -191,7 +193,7 @@ export function CalendarSidebarDrawer({ style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]} > - Import from file + {t('calendar.import.title', 'Import Calendar')} )} @@ -236,6 +238,7 @@ function Section({ }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); return ( {title} @@ -291,7 +294,7 @@ function Section({ style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} > - Set as default calendar + {t('calendar.management.set_default', 'Set as default')} )} {canRename && ( @@ -300,7 +303,7 @@ function Section({ style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} > - Edit name and description + {t('calendar.management.edit', 'Edit')} )} {canRecolor && ( @@ -329,7 +332,7 @@ function Section({ style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} > - Random color + {t('calendar.management.random_color', 'New random color')} )} @@ -340,7 +343,7 @@ function Section({ style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} > - Share… + {t('calendar.management.share', 'Share calendar')} )} {canClear && ( @@ -349,7 +352,7 @@ function Section({ style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} > - Remove all events + {t('calendar.management.clear_events', 'Clear events')} )} {canDelete && ( @@ -358,7 +361,7 @@ function Section({ style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} > - Delete calendar + {t('calendar.management.delete', 'Delete')} )} diff --git a/src/components/calendar/EventCard.tsx b/src/components/calendar/EventCard.tsx index 56b02bfe..a67606b3 100644 --- a/src/components/calendar/EventCard.tsx +++ b/src/components/calendar/EventCard.tsx @@ -11,6 +11,7 @@ import { timePattern, type TimeFormat, } from '../../lib/calendar-utils'; +import { useCalendarLocale } from '../../lib/calendar-locale'; interface EventCardProps { event: CalendarEvent; @@ -24,19 +25,19 @@ function participantCount(event: CalendarEvent): number { return event.participants ? Object.keys(event.participants).length : 0; } -function formatTimeRange(event: CalendarEvent, timeFormat?: TimeFormat): string { - const { start, end, allDay } = eventTimeRange(event); - if (allDay) return 'All day'; - const fmt = timePattern(timeFormat); - return `${format(start, fmt)} – ${format(end, fmt)}`; -} - export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }: EventCardProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const { locale, t } = useCalendarLocale(); const color = getEventColor(event, calendars); - const time = formatTimeRange(event, timeFormat); + const { start, end, allDay } = eventTimeRange(event); + const fmt = timePattern(timeFormat); + const time = allDay + ? t('calendar.events.all_day', 'All day') + : `${format(start, fmt, { locale })} – ${format(end, fmt, { locale })}`; const count = participantCount(event); + const location = event.locations ? Object.values(event.locations)[0]?.name : undefined; + const cancelled = event.status === 'cancelled'; return ( - - {event.title || 'Untitled'} + + {event.title || t('calendar.events.no_title', '(No title)')} {event.showWithoutTime && ( - All day + {t('calendar.events.all_day', 'All day')} + + )} + {event.status === 'tentative' && ( + + {t('calendar.detail.tentative', 'Tentative')} )} @@ -62,18 +68,20 @@ export function EventCard({ event, calendars, timeFormat, onPress, onLongPress } {time} )} - {event.description ? ( + {location ? ( - {event.description} + {location} ) : null} {count > 0 && ( - {count} participants + + {count} {t('calendar.participants.title', 'Participants').toLowerCase()} + )} @@ -103,6 +111,7 @@ function makeStyles(c: ThemePalette) { gap: spacing.sm, }, title: { ...typography.bodyMedium, color: c.text, flex: 1 }, + titleCancelled: { textDecorationLine: 'line-through', color: c.textMuted }, allDayBadge: { backgroundColor: c.primaryBg, paddingHorizontal: 8, diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index d5792314..f664c393 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -28,7 +28,7 @@ import { Video, X, } from 'lucide-react-native'; -import { format } from 'date-fns'; +import { format, type Locale } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; @@ -47,6 +47,7 @@ import { isOrganizer, } from '../../lib/calendar-participants'; import { getEventEditability } from '../../lib/calendar-editability'; +import { useCalendarLocale } from '../../lib/calendar-locale'; import { useSheetDrag } from '../../lib/use-sheet-drag'; type RsvpStatus = 'accepted' | 'declined' | 'tentative'; @@ -66,47 +67,56 @@ interface EventDetailSheetProps { onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise; } -function formatRange(event: CalendarEvent, timeFormat?: TimeFormat): string { +function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, locale: Locale): string { const { start, allDay } = eventTimeRange(event); // All-day events store an exclusive end (next day 00:00); show the // inclusive last day so a one-day event doesn't read as two (#318). const end = allDay ? getEventDisplayEndDate(event) : eventTimeRange(event).end; + const opts = { locale }; if (allDay) { if ( start.getFullYear() === end.getFullYear() && start.getMonth() === end.getMonth() && start.getDate() === end.getDate() ) { - return format(start, 'EEEE, MMM d, yyyy'); + return format(start, 'EEEE, MMM d, yyyy', opts); } - return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`; + return `${format(start, 'MMM d', opts)} – ${format(end, 'MMM d, yyyy', opts)}`; } - const t = timePattern(timeFormat); + const tp = timePattern(timeFormat); const sameDay = start.getFullYear() === end.getFullYear() && start.getMonth() === end.getMonth() && start.getDate() === end.getDate(); if (sameDay) { - return `${format(start, 'EEE, MMM d')} · ${format(start, t)} – ${format(end, t)}`; + return `${format(start, 'EEE, MMM d', opts)} · ${format(start, tp, opts)} – ${format(end, tp, opts)}`; } - return `${format(start, `MMM d, ${t}`)} – ${format(end, `MMM d, ${t}`)}`; + return `${format(start, `MMM d, ${tp}`, opts)} – ${format(end, `MMM d, ${tp}`, opts)}`; } -function recurrenceLabel(event: CalendarEvent): string | null { +type Translate = (key: string, fallback?: string) => string; + +function recurrenceLabel(event: CalendarEvent, t: Translate, locale: Locale): string | null { const rule = event.recurrenceRules?.[0]; if (!rule) return null; - const map: Record = { - daily: 'Daily', - weekly: 'Weekly', - monthly: 'Monthly', - yearly: 'Yearly', + 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 = map[rule.frequency] || rule.frequency; - if (rule.interval && rule.interval > 1) { - label = `Every ${rule.interval} ${rule.frequency}`; + 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))}`; } - if (rule.until) label += ` until ${format(new Date(rule.until), 'MMM d, yyyy')}`; - else if (rule.count) label += ` for ${rule.count} occurrences`; return label; } @@ -123,6 +133,7 @@ export function EventDetailSheet({ onRsvp, }: EventDetailSheetProps) { const c = useColors(); + const { locale, t } = useCalendarLocale(); const [rsvpBusy, setRsvpBusy] = React.useState(false); const styles = React.useMemo(() => makeStyles(c), [c]); const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current; @@ -171,8 +182,8 @@ export function EventDetailSheet({ const color = getEventColor(event, calendars); const calendarId = getPrimaryCalendarId(event); const calendar = calendars.find((c) => c.id === calendarId); - const range = formatRange(event, timeFormat); - const recurrence = recurrenceLabel(event); + const range = formatRange(event, timeFormat, locale); + const recurrence = recurrenceLabel(event, t, locale); const reminders = alertsToReminders(event.alerts); const participants = getParticipantList(event); const location = event.locations ? Object.values(event.locations)[0]?.name : undefined; @@ -231,7 +242,7 @@ export function EventDetailSheet({ - {event.title || 'Untitled'} + {event.title || t('calendar.events.no_title', '(No title)')} {calendar && {calendar.name}} @@ -259,7 +270,7 @@ export function EventDetailSheet({ style={styles.joinBtn} onPress={() => { void Linking.openURL(videoUri); }} > - Join video call + {t('calendar.detail.open_link', 'Open link')} ) : null} @@ -291,10 +302,10 @@ export function EventDetailSheet({ {canRsvp && ( - Going? + {t('calendar.participants.rsvp_label', 'Your response')} } active={myStatus === 'accepted'} activeColor={c.success} @@ -302,7 +313,7 @@ export function EventDetailSheet({ onPress={() => doRsvp('accepted')} /> } active={myStatus === 'tentative'} activeColor={c.warning} @@ -310,7 +321,7 @@ export function EventDetailSheet({ onPress={() => doRsvp('tentative')} /> } active={myStatus === 'declined'} activeColor={c.error} @@ -326,7 +337,7 @@ export function EventDetailSheet({ - {participants.length} participants + {participants.length} {t('calendar.participants.title', 'Participants').toLowerCase()} {participants.map((p) => ( @@ -342,11 +353,13 @@ export function EventDetailSheet({ {p.name || p.email || 'Unknown'} {p.isOrganizer ? ( - (organizer) + + {' '}({t('calendar.participants.organizer', 'Organizer').toLowerCase()}) + ) : null} - {p.isOrganizer ? '' : statusLabel(p.status)} + {p.isOrganizer ? '' : statusLabel(p.status, t)} ))} @@ -359,21 +372,21 @@ export function EventDetailSheet({ {onEdit && canEdit && ( } - label="Edit" + label={t('calendar.participants.edit', 'Edit')} onPress={() => onEdit(event)} /> )} {onDuplicate && ( } - label="Duplicate" + label={t('calendar.events.duplicate', 'Duplicate')} onPress={() => onDuplicate(event)} /> )} {onDelete && canEdit && ( } - label="Delete" + label={t('calendar.management.delete', 'Delete')} onPress={() => onDelete(event)} destructive /> @@ -482,16 +495,16 @@ function statusColor(c: ThemePalette, status?: string): string { } } -function statusLabel(status?: string): string { +function statusLabel(status: string | undefined, t: Translate): string { switch (status) { case 'accepted': - return 'Going'; + return t('calendar.participants.accepted', 'Accepted'); case 'declined': - return 'Declined'; + return t('calendar.participants.declined', 'Declined'); case 'tentative': - return 'Maybe'; + return t('calendar.participants.tentative', 'Tentative'); case 'needs-action': - return 'No reply'; + return t('calendar.participants.needs_action', 'Needs action'); default: return status || ''; } diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index f1b8407f..73089d5d 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -42,9 +42,12 @@ import { getEventEndDate, getEventStartDate, getPrimaryCalendarId, + timePattern, } from '../../lib/calendar-utils'; import { getEffectiveTimeZone } from '../../lib/calendar-timezone'; +import { useSettingsStore } from '../../stores/settings-store'; import { canCreateEventsIn } from '../../lib/calendar-editability'; +import { useCalendarLocale } from '../../lib/calendar-locale'; import { alertsToReminders, remindersToAlerts, @@ -65,13 +68,13 @@ import { RecurrenceEditor } from './RecurrenceEditor'; type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'; -const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [ - { value: 'none', label: 'Does not repeat' }, - { value: 'daily', label: 'Daily' }, - { value: 'weekly', label: 'Weekly' }, - { value: 'monthly', label: 'Monthly' }, - { value: 'yearly', label: 'Yearly' }, - { value: 'custom', label: 'Custom…' }, +const RECURRENCE_OPTIONS: { value: RecurrenceOption; key: string; fallback: string }[] = [ + { value: 'none', key: 'calendar.recurrence.none', fallback: 'Does not repeat' }, + { value: 'daily', key: 'calendar.recurrence.daily', fallback: 'Daily' }, + { value: 'weekly', key: 'calendar.recurrence.weekly', fallback: 'Weekly' }, + { value: 'monthly', key: 'calendar.recurrence.monthly', fallback: 'Monthly' }, + { value: 'yearly', key: 'calendar.recurrence.yearly', fallback: 'Yearly' }, + { value: 'custom', key: 'calendar.recurrence.custom', fallback: 'Custom…' }, ]; interface EventModalProps { @@ -172,6 +175,9 @@ export function EventModal({ }: EventModalProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const { locale, t } = useCalendarLocale(); + const timeFormat = useSettingsStore((s) => s.calendarTimeFormat); + const timeLabelPattern = timePattern(timeFormat); const isEdit = !!event; const [title, setTitle] = React.useState(''); @@ -363,10 +369,13 @@ export function EventModal({ return true; }); }, [calendars, calendarId, event, isSubscriptionCalendar]); + const recurrenceOption = RECURRENCE_OPTIONS.find((o) => o.value === recurrence); const recurrenceLabel = recurrence === 'custom' - ? (customRule && buildRecurrenceSummary(customRule)) || 'Custom' - : RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None'; + ? (customRule && buildRecurrenceSummary(customRule)) || t('calendar.recurrence.custom', 'Custom…') + : recurrenceOption + ? t(recurrenceOption.key, recurrenceOption.fallback) + : t('calendar.recurrence.none', 'Does not repeat'); const addReminder = (minutesBefore: number) => { setReminderPickerOpen(false); @@ -395,7 +404,9 @@ export function EventModal({ - {isEdit ? 'Edit event' : 'New event'} + + {isEdit ? t('calendar.events.edit', 'Edit event') : t('calendar.events.new_event', 'New event')} + @@ -415,14 +426,14 @@ export function EventModal({
} - label="Calendar" + label={t('calendar.form.calendar_select', 'Calendar')} > )} - {selectedCalendar?.name || 'Pick a calendar'} + {selectedCalendar?.name || t('calendar.form.calendar_select', 'Calendar')} @@ -469,9 +480,9 @@ export function EventModal({ )}
-
} label="Time"> +
} label={t('calendar.form.time', 'Time')}> - All-day + {t('calendar.form.all_day_event', 'All-day event')} - Starts + {t('calendar.form.start_date', 'Start date')} setShowStartDate(true)} > - {format(start, 'EEE, MMM d')} + {format(start, 'EEE, MMM d', { locale })} {!allDay && ( setShowStartTime(true)} > - {format(start, 'HH:mm')} + {format(start, timeLabelPattern, { locale })} )} - Ends + {t('calendar.form.end_date', 'End date')} setShowEndDate(true)} > - {format(end, 'EEE, MMM d')} + {format(end, 'EEE, MMM d', { locale })} {!allDay && ( setShowEndTime(true)} > - {format(end, 'HH:mm')} + {format(end, timeLabelPattern, { locale })} )}
-
} label="Repeat"> +
} label={t('calendar.recurrence.title', 'Recurrence')}> setRecurrenceOpen((v) => !v)} @@ -548,7 +559,7 @@ export function EventModal({ setRecurrenceEditorOpen(false); }} > - {opt.label} + {t(opt.key, opt.fallback)} ))} @@ -567,12 +578,12 @@ export function EventModal({ )} {recurrence === 'custom' && customRule && !recurrenceEditorOpen && ( setRecurrenceEditorOpen(true)}> - Edit custom recurrence + {t('calendar.recurrence.edit_custom', 'Edit custom recurrence')} )}
-
} label="Reminders"> +
} label={t('calendar.alerts.title', 'Reminders')}> {reminders.length > 0 && ( {reminders.map((r) => ( @@ -596,7 +607,7 @@ export function EventModal({ > - {reminders.length === 0 ? 'Add reminder' : 'Add another reminder'} + {t('calendar.alerts.add', 'Add reminder')} )} @@ -615,17 +626,17 @@ export function EventModal({ )}
-
} label="Location"> +
} label={t('calendar.form.location', 'Location')}>
-
} label="Video call"> +
} label={t('calendar.form.meeting_link', 'Meeting link')}> } - label="Description" + label={t('calendar.form.description', 'Description')} > } - label="Participants" + label={t('calendar.participants.title', 'Participants')} > - Delete event + {t('calendar.events.delete', 'Delete event')} )} diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx index aac588ec..25ccc15b 100644 --- a/src/components/calendar/MonthView.tsx +++ b/src/components/calendar/MonthView.tsx @@ -5,6 +5,7 @@ import { endOfMonth, endOfWeek, format, + getISOWeek, getWeek, isSameDay, isSameMonth, @@ -19,11 +20,14 @@ import { buildEventDayIndex, eventsOnDayFromIndex, getEventColor, + getEventStartDate, + timePattern, type EventDayIndex, + type TimeFormat, } from '../../lib/calendar-utils'; +import { useCalendarLocale } from '../../lib/calendar-locale'; -const SUN_FIRST = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; -const MON_FIRST = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; +type WeekStart = 0 | 1 | 6; interface MonthViewProps { currentDate: Date; @@ -31,12 +35,23 @@ interface MonthViewProps { events: CalendarEvent[]; calendars: Calendar[]; eventsByDay?: EventDayIndex; - weekStartsOn?: 0 | 1; + weekStartsOn?: WeekStart; showWeekNumbers?: boolean; + // Render compact event chips (title + start time) instead of dots (#666). + showTimeInMonthView?: boolean; + timeFormat?: TimeFormat; onSelectDate: (date: Date) => void; onLongPressDate?: (date: Date) => void; } +// ISO week numbers when the week starts on Monday (ISO 8601 weeks do), else +// the locale-style numbering with the given start day — plain +// `getWeek(date, { weekStartsOn })` is not ISO and drifts around New Year. +export function weekNumberFor(date: Date, weekStartsOn: WeekStart): number { + if (weekStartsOn === 1) return getISOWeek(date); + return getWeek(date, { weekStartsOn }); +} + function MonthViewInner({ currentDate, selectedDate, @@ -45,12 +60,18 @@ function MonthViewInner({ eventsByDay, weekStartsOn = 0, showWeekNumbers = false, + showTimeInMonthView = false, + timeFormat, onSelectDate, onLongPressDate, }: MonthViewProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - const weekdayLabels = weekStartsOn === 1 ? MON_FIRST : SUN_FIRST; + const { locale } = useCalendarLocale(); + const weekdayLabels = React.useMemo(() => { + const start = startOfWeek(new Date(), { weekStartsOn }); + return Array.from({ length: 7 }, (_, i) => format(addDays(start, i), 'EEEEE', { locale })); + }, [weekStartsOn, locale]); const index = React.useMemo( () => eventsByDay ?? buildEventDayIndex(events), [eventsByDay, events], @@ -82,13 +103,15 @@ function MonthViewInner({ const selected = isSameDay(d, selectedDate); const dayEvents = eventsOnDayFromIndex(index, d); const MAX_DOTS = dayEvents.length > 3 ? 2 : 3; - const visibleDots = dayEvents.slice(0, MAX_DOTS); - const overflow = Math.max(0, dayEvents.length - MAX_DOTS); + const MAX_CHIPS = dayEvents.length > 2 ? 1 : 2; + const maxVisible = showTimeInMonthView ? MAX_CHIPS : MAX_DOTS; + const visible = dayEvents.slice(0, maxVisible); + const overflow = Math.max(0, dayEvents.length - maxVisible); return ( onSelectDate(d)} onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined} > @@ -106,9 +129,9 @@ function MonthViewInner({ {format(d, 'd')} - {dayEvents.length > 0 && ( + {dayEvents.length > 0 && !showTimeInMonthView && ( - {visibleDots.map((event, idx) => ( + {visible.map((event, idx) => ( )} + {dayEvents.length > 0 && showTimeInMonthView && ( + + {visible.map((event, idx) => { + const color = getEventColor(event, calendars); + const cancelled = event.status === 'cancelled'; + return ( + + + {event.showWithoutTime + ? (event.title || '') + : `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ${event.title || ''}`} + + + ); + })} + {overflow > 0 && ( + +{overflow} + )} + + )} ); }; @@ -134,7 +180,7 @@ function MonthViewInner({ {showWeekNumbers ? ( rows.map((row, rowIdx) => ( - {getWeek(row[0], { weekStartsOn })} + {weekNumberFor(row[0], weekStartsOn)} {row.map((d, i) => renderDay(d, `${rowIdx}-${i}`, true))} )) @@ -160,7 +206,7 @@ function makeStyles(c: ThemePalette) { color: c.textMuted, }, daysGrid: { flexDirection: 'row', flexWrap: 'wrap' }, - weekRow: { flexDirection: 'row', alignItems: 'center' }, + weekRow: { flexDirection: 'row', alignItems: 'flex-start' }, weekNumberLabel: { width: 24, textAlign: 'center', @@ -176,6 +222,7 @@ function makeStyles(c: ThemePalette) { }, dayCell: { width: '14.28%', alignItems: 'center', paddingVertical: 4 }, dayCellFlex: { width: undefined, flex: 1 }, + dayCellTall: { minHeight: 74, paddingHorizontal: 1 }, dayNumber: { width: 36, height: 36, @@ -204,11 +251,20 @@ function makeStyles(c: ThemePalette) { height: componentSizes.eventDot, borderRadius: componentSizes.eventDot / 2, }, + chipsCol: { width: '100%', gap: 1, marginTop: 2, alignItems: 'stretch' }, + chip: { + borderRadius: radius.xs, + paddingHorizontal: 2, + paddingVertical: 1, + }, + chipText: { color: c.textInverse, fontSize: 9, lineHeight: 11 }, + chipTextCancelled: { textDecorationLine: 'line-through' }, overflowText: { color: c.textMuted, fontSize: 9, lineHeight: 10, marginLeft: 1, + textAlign: 'center', }, }); } diff --git a/src/components/calendar/RecurrenceScopeDialog.tsx b/src/components/calendar/RecurrenceScopeDialog.tsx index dc80a4a7..509c6a01 100644 --- a/src/components/calendar/RecurrenceScopeDialog.tsx +++ b/src/components/calendar/RecurrenceScopeDialog.tsx @@ -3,6 +3,7 @@ import { Modal, View, Text, Pressable, StyleSheet } from 'react-native'; import { Repeat, Trash2 } from 'lucide-react-native'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; import { Button } from '..'; export type RecurrenceEditScope = 'this' | 'this_and_future' | 'all'; @@ -14,10 +15,10 @@ interface RecurrenceScopeDialogProps { onClose: () => void; } -const OPTIONS: { value: RecurrenceEditScope; label: string }[] = [ - { value: 'this', label: 'This event' }, - { value: 'this_and_future', label: 'This and following events' }, - { value: 'all', label: 'All events' }, +const OPTIONS: { value: RecurrenceEditScope; key: string; fallback: string }[] = [ + { value: 'this', key: 'calendar.recurrence_scope.this_event', fallback: 'This event only' }, + { value: 'this_and_future', key: 'calendar.recurrence_scope.this_and_future', fallback: 'This and following events' }, + { value: 'all', key: 'calendar.recurrence_scope.all_events', fallback: 'All events' }, ]; export function RecurrenceScopeDialog({ @@ -28,6 +29,7 @@ export function RecurrenceScopeDialog({ }: RecurrenceScopeDialogProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); const [selected, setSelected] = React.useState('this'); const isDelete = actionType === 'delete'; @@ -49,10 +51,15 @@ export function RecurrenceScopeDialog({ - {isDelete ? 'Delete recurring event' : 'Edit recurring event'} + {isDelete + ? t('calendar.recurrence_scope.delete_title', 'Delete recurring event') + : t('calendar.recurrence_scope.edit_title', 'Edit recurring event')} - Choose which occurrences to {isDelete ? 'delete' : 'change'}. + {t( + 'calendar.recurrence_scope.description', + 'This is a recurring event. Which events would you like to modify?', + )} @@ -69,7 +76,7 @@ export function RecurrenceScopeDialog({ {active && } - {opt.label} + {t(opt.key, opt.fallback)} ); })} @@ -77,14 +84,16 @@ export function RecurrenceScopeDialog({ diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index 49a82c66..11f21f01 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -10,6 +10,7 @@ import { addDays, format, isSameDay, isToday, startOfWeek } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { useCalendarLocale } from '../../lib/calendar-locale'; import { buildEventDayIndex, buildTimedFullDayWeekSegments, @@ -37,7 +38,7 @@ interface WeekViewProps { onSelectDate?: (date: Date) => void; onSelectEvent?: (event: CalendarEvent) => void; onCreateAtTime?: (date: Date) => void; - weekStartsOn?: 0 | 1; + weekStartsOn?: 0 | 1 | 6; timeFormat?: '12h' | '24h'; } @@ -54,6 +55,7 @@ function WeekViewInner({ }: WeekViewProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const { locale } = useCalendarLocale(); const scrollRef = React.useRef(null); const index = React.useMemo( @@ -136,7 +138,7 @@ function WeekViewInner({ style={styles.dayHeaderCell} onPress={() => onSelectDate?.(day)} > - {format(day, 'EEE')} + {format(day, 'EEE', { locale })} {height > 32 && ( - {minutesToTimeLabel(startMinutes)} + {minutesToTimeLabel(startMinutes, timeFormat)} )} @@ -312,10 +314,17 @@ function WeekViewInner({ export const WeekView = React.memo(WeekViewInner); -function minutesToTimeLabel(minutes: number): string { +// Event-block start label; honours the 12h/24h setting like the gutter does +// (webmail's formatSnapTime). +function minutesToTimeLabel(minutes: number, timeFormat: '12h' | '24h'): string { const h = Math.floor(minutes / 60); const m = minutes % 60; - return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`; + const mm = m.toString().padStart(2, '0'); + if (timeFormat === '12h') { + const suffix = h < 12 ? 'AM' : 'PM'; + return `${(h % 12) || 12}:${mm} ${suffix}`; + } + return `${h.toString().padStart(2, '0')}:${mm}`; } function makeStyles(c: ThemePalette) { diff --git a/src/components/settings/CalendarSettings.tsx b/src/components/settings/CalendarSettings.tsx index 459e4319..aae39689 100644 --- a/src/components/settings/CalendarSettings.tsx +++ b/src/components/settings/CalendarSettings.tsx @@ -5,10 +5,32 @@ import { type CalendarView, type FirstDayOfWeek, type TimeFormat, - type CalendarHoverPreview, } from '../../stores/settings-store'; +import { useLocaleStore } from '../../stores/locale-store'; +import { AUTO_TIME_ZONE, getDeviceTimeZone, isValidTimeZone } from '../../lib/calendar-timezone'; + +// A compact list of IANA zones for the picker (#755). The device zone and +// any previously stored value are always offered too. +const COMMON_TIME_ZONES = [ + 'UTC', + 'Europe/London', 'Europe/Dublin', 'Europe/Lisbon', + 'Europe/Berlin', 'Europe/Paris', 'Europe/Madrid', 'Europe/Rome', 'Europe/Amsterdam', + 'Europe/Brussels', 'Europe/Vienna', 'Europe/Zurich', 'Europe/Prague', 'Europe/Warsaw', + 'Europe/Stockholm', 'Europe/Oslo', 'Europe/Copenhagen', 'Europe/Helsinki', 'Europe/Riga', + 'Europe/Kyiv', 'Europe/Athens', 'Europe/Istanbul', 'Europe/Moscow', + 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Phoenix', + 'America/Los_Angeles', 'America/Anchorage', 'Pacific/Honolulu', + 'America/Toronto', 'America/Vancouver', 'America/Mexico_City', 'America/Bogota', + 'America/Lima', 'America/Santiago', 'America/Sao_Paulo', 'America/Argentina/Buenos_Aires', + 'Africa/Cairo', 'Africa/Johannesburg', 'Africa/Lagos', 'Africa/Nairobi', + 'Asia/Dubai', 'Asia/Tehran', 'Asia/Karachi', 'Asia/Kolkata', 'Asia/Dhaka', 'Asia/Bangkok', + 'Asia/Jakarta', 'Asia/Singapore', 'Asia/Hong_Kong', 'Asia/Shanghai', 'Asia/Taipei', + 'Asia/Seoul', 'Asia/Tokyo', 'Australia/Perth', 'Australia/Adelaide', 'Australia/Sydney', + 'Pacific/Auckland', +]; export function CalendarSettings() { + const t = useLocaleStore((s) => s.t); const hydrated = useSettingsStore((s) => s.hydrated); const hydrate = useSettingsStore((s) => s.hydrate); const update = useSettingsStore((s) => s.updateSetting); @@ -16,9 +38,9 @@ export function CalendarSettings() { const viewMode = useSettingsStore((s) => s.calendarDefaultView); const firstDay = useSettingsStore((s) => s.calendarFirstDayOfWeek); const timeFormat = useSettingsStore((s) => s.calendarTimeFormat); + const timeZone = useSettingsStore((s) => s.calendarTimeZone); const showTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth); const showWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers); - const hoverPreview = useSettingsStore((s) => s.calendarHoverPreview); const birthdayCal = useSettingsStore((s) => s.showBirthdayCalendar); const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks); const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar); @@ -27,46 +49,80 @@ export function CalendarSettings() { if (!hydrated) void hydrate(); }, [hydrated, hydrate]); + const deviceZone = getDeviceTimeZone(); + const timeZoneOptions = React.useMemo(() => { + const zones = new Set([deviceZone, ...COMMON_TIME_ZONES]); + if (timeZone && timeZone !== AUTO_TIME_ZONE && isValidTimeZone(timeZone)) zones.add(timeZone); + return [ + { + value: AUTO_TIME_ZONE, + label: `${t('calendar.settings.time_zone_auto', 'Device time zone')} (${deviceZone})`, + }, + ...[...zones].sort().map((z) => ({ value: z, label: z.replace(/_/g, ' ') })), + ]; + }, [deviceZone, timeZone, t]); + + // The mobile app has no dedicated day grid ("day" falls back to the + // agenda), so the option isn't offered; a synced "day" value shows as Agenda. + const effectiveView: CalendarView = viewMode === 'day' ? 'agenda' : viewMode; + return ( - - + + update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)} options={[ - { value: '1', label: 'Monday' }, - { value: '0', label: 'Sunday' }, + { value: '1', label: t('calendar.days.monday', 'Monday') }, + { value: '6', label: t('calendar.days.saturday', 'Saturday') }, + { value: '0', label: t('calendar.days.sunday', 'Sunday') }, ]} /> - + update('calendarTimeFormat', v as TimeFormat)} options={[ - { value: '12h', label: '12-hour' }, - { value: '24h', label: '24-hour' }, + { value: '12h', label: t('calendar.settings.time_format_12h', '12-hour') }, + { value: '24h', label: t('calendar.settings.time_format_24h', '24-hour') }, ]} /> + update('calendarHoverPreview', v as CalendarHoverPreview)} - options={[ - { value: 'instant', label: 'Instant' }, - { value: 'delay-500ms', label: '500ms delay' }, - { value: 'delay-1s', label: '1s delay' }, - { value: 'delay-2s', label: '2s delay' }, - { value: 'off', label: 'Off' }, - ]} - /> - - - = { + en: enUS, ar, ca, cs, da, de, es, fa: faIR, fr, he, hu, it, nl, nb, pl, pt, ro, ru, sk, tr, uk, ja, ko, mn, zh: zhCN, 'zh-TW': zhTW, lv, +}; + +export function getDateFnsLocale(code: LocaleCode | string | null | undefined): Locale { + return (code && DATE_FNS_LOCALES[code as LocaleCode]) || enUS; +} + +/** `{ locale }` options for date-fns `format()` plus the translate function. */ +export function useCalendarLocale(): { locale: Locale; t: (key: string, fallback?: string) => string } { + 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 5c17bc7c..e2550ffc 100644 --- a/src/lib/calendar-utils.ts +++ b/src/lib/calendar-utils.ts @@ -472,9 +472,11 @@ export function getEventColor( * owning JMAP account + the calendar id so it stays unique across accounts. */ export function sharedCalendarColorKey( - cal: Pick, + cal: Pick, ): string { - return `${cal.accountId ?? ''}|${cal.id}`; + // Keyed on the raw server id (like webmail) so the key doesn't depend on + // the store's `${accountId}:${id}` namespacing format. + return `${cal.accountId ?? ''}|${cal.originalId ?? cal.id}`; } /** diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index b149ca6e..9a49f885 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -33,7 +33,9 @@ import { subMonths, addWeeks, subWeeks, + type Locale, } from 'date-fns'; +import { useCalendarLocale } from '../lib/calendar-locale'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { Button } from '../components'; @@ -89,10 +91,12 @@ type PendingAction = const AGENDA_DAYS = 30; const RANGE_BUFFER_DAYS = 14; +type WeekStart = 0 | 1 | 6; + function rangeForView( viewMode: ViewMode, currentDate: Date, - weekStartsOn: 0 | 1, + weekStartsOn: WeekStart, ): { after: Date; before: Date } { if (viewMode === 'month') { const start = startOfWeek(startOfMonth(currentDate), { weekStartsOn }); @@ -113,18 +117,19 @@ function rangeForView( function headerTitle( viewMode: ViewMode, currentDate: Date, - weekStartsOn: 0 | 1, + weekStartsOn: WeekStart, + locale: Locale, ): string { - if (viewMode === 'month') return format(currentDate, 'MMMM yyyy'); + if (viewMode === 'month') return format(currentDate, 'MMMM yyyy', { locale }); if (viewMode === 'week') { const start = startOfWeek(currentDate, { weekStartsOn }); const end = endOfWeek(currentDate, { weekStartsOn }); if (start.getMonth() === end.getMonth()) { - return `${format(start, 'MMM d')} – ${format(end, 'd, yyyy')}`; + return `${format(start, 'MMM d', { locale })} – ${format(end, 'd, yyyy', { locale })}`; } - return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`; + return `${format(start, 'MMM d', { locale })} – ${format(end, 'MMM d, yyyy', { locale })}`; } - return format(currentDate, 'MMMM yyyy'); + return format(currentDate, 'MMMM yyyy', { locale }); } export default function CalendarScreen() { @@ -132,7 +137,9 @@ export default function CalendarScreen() { const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); const currentUserEmails = useUserCalendarAddresses(); + const { locale } = useCalendarLocale(); const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView); + const calendarShowTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth); const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek); const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers); const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat); @@ -626,9 +633,13 @@ export default function CalendarScreen() { - {headerTitle(viewMode, currentDate, calendarFirstDayOfWeek)} + + {headerTitle(viewMode, currentDate, calendarFirstDayOfWeek, locale)} + - {isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')} + {isSelectedToday + ? t('calendar.views.today', 'Today') + : format(selectedDate, 'EEE, MMM d', { locale })} @@ -668,7 +679,7 @@ export default function CalendarScreen() { @@ -691,6 +702,8 @@ export default function CalendarScreen() { calendars={calendars} weekStartsOn={calendarFirstDayOfWeek} showWeekNumbers={calendarShowWeekNumbers} + showTimeInMonthView={calendarShowTimeInMonth} + timeFormat={calendarTimeFormat} onSelectDate={handleSelectDate} onLongPressDate={openCreate} /> @@ -724,7 +737,9 @@ export default function CalendarScreen() { - {isSelectedToday ? "Today's Events" : format(selectedDate, 'EEEE, MMMM d')} + {isSelectedToday + ? t('calendar.events.today_header', 'Today') + : format(selectedDate, 'EEEE, MMMM d', { locale })} {loading && } @@ -870,6 +885,7 @@ function DayEventList({ }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); const dayEvents = React.useMemo( () => eventsOnDayFromIndex(eventsByDay, date), [eventsByDay, date], @@ -883,8 +899,8 @@ function DayEventList({ } > - No events - Tap + to create one + {t('calendar.events.no_events', 'No events')} + {t('calendar.events.tap_to_create', 'Tap + to create one')} ); } diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index 9f8cd157..0bb3f829 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -77,7 +77,8 @@ export function normalizeBottomQuickActions(value: unknown): QuickAction[] { } export type ArchiveMode = 'single' | 'year' | 'month'; export type CalendarView = 'month' | 'week' | 'day' | 'agenda'; -export type FirstDayOfWeek = 0 | 1; +// 0 = Sunday, 1 = Monday, 6 = Saturday (same values as the webmail). +export type FirstDayOfWeek = 0 | 1 | 6; export type TimeFormat = '12h' | '24h'; // Email-list date rendering style. Mirrors the webmail `dateFormat` setting: // smart — locale-aware, age-bucketed (today→time, this week→weekday+time, older→date)