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('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') },
]}
/>
+
+
+
-
-
-
= {
+ 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)