feat: localize the calendar, add Saturday week start, ISO week numbers, month chips and 12h labels (#666)

This commit is contained in:
Linus Rath
2026-08-29 18:35:46 +02:00
parent 2b44936a17
commit 0f80e98fbc
15 changed files with 470 additions and 198 deletions
+2 -2
View File
@@ -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. - 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. - 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 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". - 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:`. - 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 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. - 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`. - Fix hint: prefer `cal.isDefault && !cal.isShared`, exclude subscription/birthday calendars; pass `email.accountId` (if RN exposes shared mailboxes later) to `parseCalendarBlob`.
+70 -4
View File
@@ -2762,7 +2762,8 @@
"new_event": "New event", "new_event": "New event",
"new_all_day_event": "New all-day event", "new_all_day_event": "New all-day event",
"new_task": "New task", "new_task": "New task",
"go_to_today": "Go to today" "go_to_today": "Go to today",
"tap_to_create": "Tap + to create one"
}, },
"detail": { "detail": {
"add_note": "Add a note...", "add_note": "Add a note...",
@@ -2788,7 +2789,10 @@
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"delete_confirm": "Are you sure you want to delete this event?", "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": { "participants": {
"title": "Participants", "title": "Participants",
@@ -2915,7 +2919,11 @@
"hover_preview_delay_2s": "2-second delay", "hover_preview_delay_2s": "2-second delay",
"hover_preview_off": "Disabled", "hover_preview_off": "Disabled",
"show_birthday_calendar": "Contact birthday calendar", "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": { "days": {
"monday": "Monday", "monday": "Monday",
@@ -3065,7 +3073,15 @@
"kind_events": "Events", "kind_events": "Events",
"kind_tasks": "Tasks", "kind_tasks": "Tasks",
"kind_both": "Events and 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": { "subscription": {
"title": "iCal Subscription", "title": "iCal Subscription",
@@ -3145,6 +3161,56 @@
"due_today": "Today", "due_today": "Today",
"due_tomorrow": "Tomorrow", "due_tomorrow": "Tomorrow",
"overdue": "Overdue" "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": { "sharing": {
+19 -11
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { SectionList, StyleSheet, Text, View } from 'react-native'; 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 { CalendarDays } from 'lucide-react-native';
import type { Calendar, CalendarEvent } from '../../api/types'; import type { Calendar, CalendarEvent } from '../../api/types';
import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -12,6 +12,7 @@ import {
type EventDayIndex, type EventDayIndex,
type TimeFormat, type TimeFormat,
} from '../../lib/calendar-utils'; } from '../../lib/calendar-utils';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { EventCard } from './EventCard'; import { EventCard } from './EventCard';
interface AgendaViewProps { interface AgendaViewProps {
@@ -30,10 +31,14 @@ interface DaySection {
data: CalendarEvent[]; data: CalendarEvent[];
} }
function formatDayHeader(date: Date): string { function formatDayHeader(
if (isToday(date)) return 'Today'; date: Date,
if (isTomorrow(date)) return 'Tomorrow'; t: (key: string, fallback?: string) => string,
return format(date, 'EEEE, MMM d'); 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({ export function AgendaView({
@@ -47,6 +52,7 @@ export function AgendaView({
}: AgendaViewProps) { }: AgendaViewProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
const index = React.useMemo( const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events), () => eventsByDay ?? buildEventDayIndex(events),
[eventsByDay, events], [eventsByDay, events],
@@ -76,20 +82,22 @@ export function AgendaView({
return [...days.values()] return [...days.values()]
.sort((a, b) => a.getTime() - b.getTime()) .sort((a, b) => a.getTime() - b.getTime())
.map((day) => ({ .map((day) => ({
title: formatDayHeader(day), title: formatDayHeader(day, t, locale),
date: day, date: day,
// Buckets are pre-sorted by buildEventDayIndex (all-day first, then by // Buckets are pre-sorted by buildEventDayIndex (all-day first, then by
// start, then title), so no extra sort is needed here. // start, then title), so no extra sort is needed here.
data: eventsOnDayFromIndex(index, day), data: eventsOnDayFromIndex(index, day),
})); }));
}, [fromDate, daysAhead, index]); }, [fromDate, daysAhead, index, t, locale]);
if (sections.length === 0) { if (sections.length === 0) {
return ( return (
<View style={styles.empty}> <View style={styles.empty}>
<CalendarDays size={40} color={c.surfaceActive} /> <CalendarDays size={40} color={c.surfaceActive} />
<Text style={styles.emptyTitle}>No upcoming events</Text> <Text style={styles.emptyTitle}>{t('calendar.agenda.no_upcoming', 'No upcoming events')}</Text>
<Text style={styles.emptySubtitle}>The next {daysAhead} days are clear.</Text> <Text style={styles.emptySubtitle}>
{t('calendar.agenda.days_clear', 'The next {count} days are clear.').replace('{count}', String(daysAhead))}
</Text>
</View> </View>
); );
} }
@@ -107,7 +115,7 @@ export function AgendaView({
<Text style={[styles.sectionTitle, today && styles.sectionTitleToday]}> <Text style={[styles.sectionTitle, today && styles.sectionTitleToday]}>
{section.title} {section.title}
</Text> </Text>
<Text style={styles.sectionSub}>{format(section.date, 'MMM d')}</Text> <Text style={styles.sectionSub}>{format(section.date, 'MMM d', { locale })}</Text>
</View> </View>
); );
}} }}
@@ -119,7 +127,7 @@ export function AgendaView({
renderSectionFooter={({ section }) => renderSectionFooter={({ section }) =>
section.data.length === 0 ? ( section.data.length === 0 ? (
<View style={styles.emptyDayWrap}> <View style={styles.emptyDayWrap}>
<Text style={styles.emptyDayText}>No events</Text> <Text style={styles.emptyDayText}>{t('calendar.events.no_events', 'No events')}</Text>
</View> </View>
) : null ) : null
} }
@@ -21,6 +21,7 @@ import { useAnimDuration } from '../../theme/dynamic';
import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils'; import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils';
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar'; import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
import { isWritableCalendar } from '../../lib/calendar-editability'; import { isWritableCalendar } from '../../lib/calendar-editability';
import { useLocaleStore } from '../../stores/locale-store';
interface CalendarSidebarDrawerProps { interface CalendarSidebarDrawerProps {
visible: boolean; visible: boolean;
@@ -69,6 +70,7 @@ export function CalendarSidebarDrawer({
const overlayOpacity = React.useRef(new Animated.Value(0)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const openDuration = useAnimDuration(240); const openDuration = useAnimDuration(240);
const closeDuration = useAnimDuration(200); const closeDuration = useAnimDuration(200);
const t = useLocaleStore((s) => s.t);
const [expandedId, setExpandedId] = React.useState<string | null>(null); const [expandedId, setExpandedId] = React.useState<string | null>(null);
React.useEffect(() => { React.useEffect(() => {
@@ -145,24 +147,24 @@ export function CalendarSidebarDrawer({
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}> <Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
<X size={20} color={c.text} /> <X size={20} color={c.text} />
</Pressable> </Pressable>
<Text style={styles.headerTitle}>Calendars</Text> <Text style={styles.headerTitle}>{t('calendar.my_calendars', 'Calendars')}</Text>
</View> </View>
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}> <ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{calendars.length === 0 && ( {calendars.length === 0 && (
<Text style={styles.empty}>No calendars yet.</Text> <Text style={styles.empty}>{t('calendar.drawer.no_calendars', 'No calendars yet.')}</Text>
)} )}
{myCalendars.length > 0 && ( {myCalendars.length > 0 && (
<Section title="My calendars" calendars={myCalendars} {...sectionProps} /> <Section title={t('calendar.drawer.my_calendars', 'My calendars')} calendars={myCalendars} {...sectionProps} />
)} )}
{sharedCalendars.length > 0 && ( {sharedCalendars.length > 0 && (
<Section title="Shared with me" calendars={sharedCalendars} {...sectionProps} /> <Section title={t('calendar.drawer.shared_with_me', 'Shared with me')} calendars={sharedCalendars} {...sectionProps} />
)} )}
{subscribed.length > 0 && ( {subscribed.length > 0 && (
<Section title="Subscribed" calendars={subscribed} {...sectionProps} /> <Section title={t('calendar.drawer.subscribed', 'Subscribed')} calendars={subscribed} {...sectionProps} />
)} )}
{(onImport || onManageSubscriptions || onCreate) && ( {(onImport || onManageSubscriptions || onCreate) && (
@@ -173,7 +175,7 @@ export function CalendarSidebarDrawer({
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]} style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
> >
<Plus size={18} color={c.textSecondary} /> <Plus size={18} color={c.textSecondary} />
<Text style={styles.actionText}>New calendar</Text> <Text style={styles.actionText}>{t('calendar.management.new_calendar', 'New calendar')}</Text>
</Pressable> </Pressable>
)} )}
{onManageSubscriptions && ( {onManageSubscriptions && (
@@ -182,7 +184,7 @@ export function CalendarSidebarDrawer({
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]} style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
> >
<Rss size={18} color={c.textSecondary} /> <Rss size={18} color={c.textSecondary} />
<Text style={styles.actionText}>Subscriptions</Text> <Text style={styles.actionText}>{t('calendar.subscription.section_title', 'iCal Subscriptions')}</Text>
</Pressable> </Pressable>
)} )}
{onImport && ( {onImport && (
@@ -191,7 +193,7 @@ export function CalendarSidebarDrawer({
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]} style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
> >
<Upload size={18} color={c.textSecondary} /> <Upload size={18} color={c.textSecondary} />
<Text style={styles.actionText}>Import from file</Text> <Text style={styles.actionText}>{t('calendar.import.title', 'Import Calendar')}</Text>
</Pressable> </Pressable>
)} )}
</View> </View>
@@ -236,6 +238,7 @@ function Section({
}) { }) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
return ( return (
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text> <Text style={styles.sectionTitle}>{title}</Text>
@@ -291,7 +294,7 @@ function Section({
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
> >
<Star size={16} color={c.textSecondary} /> <Star size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>Set as default calendar</Text> <Text style={styles.panelRowText}>{t('calendar.management.set_default', 'Set as default')}</Text>
</Pressable> </Pressable>
)} )}
{canRename && ( {canRename && (
@@ -300,7 +303,7 @@ function Section({
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
> >
<Pencil size={16} color={c.textSecondary} /> <Pencil size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>Edit name and description</Text> <Text style={styles.panelRowText}>{t('calendar.management.edit', 'Edit')}</Text>
</Pressable> </Pressable>
)} )}
{canRecolor && ( {canRecolor && (
@@ -329,7 +332,7 @@ function Section({
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
> >
<Shuffle size={16} color={c.textSecondary} /> <Shuffle size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>Random color</Text> <Text style={styles.panelRowText}>{t('calendar.management.random_color', 'New random color')}</Text>
</Pressable> </Pressable>
)} )}
</> </>
@@ -340,7 +343,7 @@ function Section({
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
> >
<Share2 size={16} color={c.textSecondary} /> <Share2 size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>Share…</Text> <Text style={styles.panelRowText}>{t('calendar.management.share', 'Share calendar')}</Text>
</Pressable> </Pressable>
)} )}
{canClear && ( {canClear && (
@@ -349,7 +352,7 @@ function Section({
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
> >
<Eraser size={16} color={c.textSecondary} /> <Eraser size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>Remove all events</Text> <Text style={styles.panelRowText}>{t('calendar.management.clear_events', 'Clear events')}</Text>
</Pressable> </Pressable>
)} )}
{canDelete && ( {canDelete && (
@@ -358,7 +361,7 @@ function Section({
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]} style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
> >
<Trash2 size={16} color={c.error} /> <Trash2 size={16} color={c.error} />
<Text style={[styles.panelRowText, { color: c.error }]}>Delete calendar</Text> <Text style={[styles.panelRowText, { color: c.error }]}>{t('calendar.management.delete', 'Delete')}</Text>
</Pressable> </Pressable>
)} )}
</View> </View>
+23 -14
View File
@@ -11,6 +11,7 @@ import {
timePattern, timePattern,
type TimeFormat, type TimeFormat,
} from '../../lib/calendar-utils'; } from '../../lib/calendar-utils';
import { useCalendarLocale } from '../../lib/calendar-locale';
interface EventCardProps { interface EventCardProps {
event: CalendarEvent; event: CalendarEvent;
@@ -24,19 +25,19 @@ function participantCount(event: CalendarEvent): number {
return event.participants ? Object.keys(event.participants).length : 0; 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) { export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }: EventCardProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
const color = getEventColor(event, calendars); 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 count = participantCount(event);
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const cancelled = event.status === 'cancelled';
return ( return (
<Pressable <Pressable
@@ -47,12 +48,17 @@ export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }
<View style={[styles.colorBar, { backgroundColor: color }]} /> <View style={[styles.colorBar, { backgroundColor: color }]} />
<View style={styles.body}> <View style={styles.body}>
<View style={styles.headerRow}> <View style={styles.headerRow}>
<Text style={styles.title} numberOfLines={1}> <Text style={[styles.title, cancelled && styles.titleCancelled]} numberOfLines={1}>
{event.title || 'Untitled'} {event.title || t('calendar.events.no_title', '(No title)')}
</Text> </Text>
{event.showWithoutTime && ( {event.showWithoutTime && (
<View style={styles.allDayBadge}> <View style={styles.allDayBadge}>
<Text style={styles.allDayText}>All day</Text> <Text style={styles.allDayText}>{t('calendar.events.all_day', 'All day')}</Text>
</View>
)}
{event.status === 'tentative' && (
<View style={styles.allDayBadge}>
<Text style={styles.allDayText}>{t('calendar.detail.tentative', 'Tentative')}</Text>
</View> </View>
)} )}
</View> </View>
@@ -62,18 +68,20 @@ export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }
<Text style={styles.detailText}>{time}</Text> <Text style={styles.detailText}>{time}</Text>
</View> </View>
)} )}
{event.description ? ( {location ? (
<View style={styles.detailRow}> <View style={styles.detailRow}>
<MapPin size={12} color={c.textMuted} /> <MapPin size={12} color={c.textMuted} />
<Text style={styles.detailText} numberOfLines={1}> <Text style={styles.detailText} numberOfLines={1}>
{event.description} {location}
</Text> </Text>
</View> </View>
) : null} ) : null}
{count > 0 && ( {count > 0 && (
<View style={styles.detailRow}> <View style={styles.detailRow}>
<Users size={12} color={c.textMuted} /> <Users size={12} color={c.textMuted} />
<Text style={styles.detailText}>{count} participants</Text> <Text style={styles.detailText}>
{count} {t('calendar.participants.title', 'Participants').toLowerCase()}
</Text>
</View> </View>
)} )}
</View> </View>
@@ -103,6 +111,7 @@ function makeStyles(c: ThemePalette) {
gap: spacing.sm, gap: spacing.sm,
}, },
title: { ...typography.bodyMedium, color: c.text, flex: 1 }, title: { ...typography.bodyMedium, color: c.text, flex: 1 },
titleCancelled: { textDecorationLine: 'line-through', color: c.textMuted },
allDayBadge: { allDayBadge: {
backgroundColor: c.primaryBg, backgroundColor: c.primaryBg,
paddingHorizontal: 8, paddingHorizontal: 8,
+50 -37
View File
@@ -28,7 +28,7 @@ import {
Video, Video,
X, X,
} from 'lucide-react-native'; } from 'lucide-react-native';
import { format } from 'date-fns'; import { format, type Locale } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types'; import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
@@ -47,6 +47,7 @@ import {
isOrganizer, isOrganizer,
} from '../../lib/calendar-participants'; } from '../../lib/calendar-participants';
import { getEventEditability } from '../../lib/calendar-editability'; import { getEventEditability } from '../../lib/calendar-editability';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { useSheetDrag } from '../../lib/use-sheet-drag'; import { useSheetDrag } from '../../lib/use-sheet-drag';
type RsvpStatus = 'accepted' | 'declined' | 'tentative'; type RsvpStatus = 'accepted' | 'declined' | 'tentative';
@@ -66,47 +67,56 @@ interface EventDetailSheetProps {
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<void>; onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<void>;
} }
function formatRange(event: CalendarEvent, timeFormat?: TimeFormat): string { function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, locale: Locale): string {
const { start, allDay } = eventTimeRange(event); const { start, allDay } = eventTimeRange(event);
// All-day events store an exclusive end (next day 00:00); show the // 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). // inclusive last day so a one-day event doesn't read as two (#318).
const end = allDay ? getEventDisplayEndDate(event) : eventTimeRange(event).end; const end = allDay ? getEventDisplayEndDate(event) : eventTimeRange(event).end;
const opts = { locale };
if (allDay) { if (allDay) {
if ( if (
start.getFullYear() === end.getFullYear() && start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() && start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate() 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 = const sameDay =
start.getFullYear() === end.getFullYear() && start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() && start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate(); start.getDate() === end.getDate();
if (sameDay) { 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]; const rule = event.recurrenceRules?.[0];
if (!rule) return null; if (!rule) return null;
const map: Record<string, string> = { const freq = rule.frequency.toLowerCase();
daily: 'Daily', const unitKey: Record<string, [string, string]> = {
weekly: 'Weekly', daily: ['calendar.recurrence.every_n_days', 'Every {count} days'],
monthly: 'Monthly', weekly: ['calendar.recurrence.every_n_weeks', 'Every {count} weeks'],
yearly: 'Yearly', 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; let label: string;
if (rule.interval && rule.interval > 1) { if (rule.interval && rule.interval > 1 && unitKey[freq]) {
label = `Every ${rule.interval} ${rule.frequency}`; 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; return label;
} }
@@ -123,6 +133,7 @@ export function EventDetailSheet({
onRsvp, onRsvp,
}: EventDetailSheetProps) { }: EventDetailSheetProps) {
const c = useColors(); const c = useColors();
const { locale, t } = useCalendarLocale();
const [rsvpBusy, setRsvpBusy] = React.useState(false); const [rsvpBusy, setRsvpBusy] = React.useState(false);
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current; 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 color = getEventColor(event, calendars);
const calendarId = getPrimaryCalendarId(event); const calendarId = getPrimaryCalendarId(event);
const calendar = calendars.find((c) => c.id === calendarId); const calendar = calendars.find((c) => c.id === calendarId);
const range = formatRange(event, timeFormat); const range = formatRange(event, timeFormat, locale);
const recurrence = recurrenceLabel(event); const recurrence = recurrenceLabel(event, t, locale);
const reminders = alertsToReminders(event.alerts); const reminders = alertsToReminders(event.alerts);
const participants = getParticipantList(event); const participants = getParticipantList(event);
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined; const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
@@ -231,7 +242,7 @@ export function EventDetailSheet({
<View style={[styles.colorBar, { backgroundColor: color }]} /> <View style={[styles.colorBar, { backgroundColor: color }]} />
<View style={styles.headerText}> <View style={styles.headerText}>
<Text style={[styles.title, isCancelled && styles.titleCancelled]}> <Text style={[styles.title, isCancelled && styles.titleCancelled]}>
{event.title || 'Untitled'} {event.title || t('calendar.events.no_title', '(No title)')}
</Text> </Text>
{calendar && <Text style={styles.subtitle}>{calendar.name}</Text>} {calendar && <Text style={styles.subtitle}>{calendar.name}</Text>}
</View> </View>
@@ -259,7 +270,7 @@ export function EventDetailSheet({
style={styles.joinBtn} style={styles.joinBtn}
onPress={() => { void Linking.openURL(videoUri); }} onPress={() => { void Linking.openURL(videoUri); }}
> >
<Text style={styles.joinBtnText}>Join video call</Text> <Text style={styles.joinBtnText}>{t('calendar.detail.open_link', 'Open link')}</Text>
</Pressable> </Pressable>
</View> </View>
) : null} ) : null}
@@ -291,10 +302,10 @@ export function EventDetailSheet({
{canRsvp && ( {canRsvp && (
<View style={styles.rsvpBlock}> <View style={styles.rsvpBlock}>
<Text style={styles.rsvpPrompt}>Going?</Text> <Text style={styles.rsvpPrompt}>{t('calendar.participants.rsvp_label', 'Your response')}</Text>
<View style={styles.rsvpButtons}> <View style={styles.rsvpButtons}>
<RsvpButton <RsvpButton
label="Yes" label={t('calendar.participants.accepted', 'Accepted')}
icon={<Check size={16} color={myStatus === 'accepted' ? c.textInverse : c.success} />} icon={<Check size={16} color={myStatus === 'accepted' ? c.textInverse : c.success} />}
active={myStatus === 'accepted'} active={myStatus === 'accepted'}
activeColor={c.success} activeColor={c.success}
@@ -302,7 +313,7 @@ export function EventDetailSheet({
onPress={() => doRsvp('accepted')} onPress={() => doRsvp('accepted')}
/> />
<RsvpButton <RsvpButton
label="Maybe" label={t('calendar.participants.tentative', 'Tentative')}
icon={<HelpCircle size={16} color={myStatus === 'tentative' ? c.textInverse : c.warning} />} icon={<HelpCircle size={16} color={myStatus === 'tentative' ? c.textInverse : c.warning} />}
active={myStatus === 'tentative'} active={myStatus === 'tentative'}
activeColor={c.warning} activeColor={c.warning}
@@ -310,7 +321,7 @@ export function EventDetailSheet({
onPress={() => doRsvp('tentative')} onPress={() => doRsvp('tentative')}
/> />
<RsvpButton <RsvpButton
label="No" label={t('calendar.participants.declined', 'Declined')}
icon={<X size={16} color={myStatus === 'declined' ? c.textInverse : c.error} />} icon={<X size={16} color={myStatus === 'declined' ? c.textInverse : c.error} />}
active={myStatus === 'declined'} active={myStatus === 'declined'}
activeColor={c.error} activeColor={c.error}
@@ -326,7 +337,7 @@ export function EventDetailSheet({
<View style={styles.participantsHeader}> <View style={styles.participantsHeader}>
<Users size={16} color={c.textMuted} /> <Users size={16} color={c.textMuted} />
<Text style={styles.participantsHeaderText}> <Text style={styles.participantsHeaderText}>
{participants.length} participants {participants.length} {t('calendar.participants.title', 'Participants').toLowerCase()}
</Text> </Text>
</View> </View>
{participants.map((p) => ( {participants.map((p) => (
@@ -342,11 +353,13 @@ export function EventDetailSheet({
<Text style={styles.participantName} numberOfLines={1}> <Text style={styles.participantName} numberOfLines={1}>
{p.name || p.email || 'Unknown'} {p.name || p.email || 'Unknown'}
{p.isOrganizer ? ( {p.isOrganizer ? (
<Text style={styles.participantOrganizer}> (organizer)</Text> <Text style={styles.participantOrganizer}>
{' '}({t('calendar.participants.organizer', 'Organizer').toLowerCase()})
</Text>
) : null} ) : null}
</Text> </Text>
<Text style={styles.participantStatus}> <Text style={styles.participantStatus}>
{p.isOrganizer ? '' : statusLabel(p.status)} {p.isOrganizer ? '' : statusLabel(p.status, t)}
</Text> </Text>
</View> </View>
))} ))}
@@ -359,21 +372,21 @@ export function EventDetailSheet({
{onEdit && canEdit && ( {onEdit && canEdit && (
<ActionButton <ActionButton
icon={<Pencil size={18} color={c.text} />} icon={<Pencil size={18} color={c.text} />}
label="Edit" label={t('calendar.participants.edit', 'Edit')}
onPress={() => onEdit(event)} onPress={() => onEdit(event)}
/> />
)} )}
{onDuplicate && ( {onDuplicate && (
<ActionButton <ActionButton
icon={<Copy size={18} color={c.text} />} icon={<Copy size={18} color={c.text} />}
label="Duplicate" label={t('calendar.events.duplicate', 'Duplicate')}
onPress={() => onDuplicate(event)} onPress={() => onDuplicate(event)}
/> />
)} )}
{onDelete && canEdit && ( {onDelete && canEdit && (
<ActionButton <ActionButton
icon={<Trash2 size={18} color={c.error} />} icon={<Trash2 size={18} color={c.error} />}
label="Delete" label={t('calendar.management.delete', 'Delete')}
onPress={() => onDelete(event)} onPress={() => onDelete(event)}
destructive 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) { switch (status) {
case 'accepted': case 'accepted':
return 'Going'; return t('calendar.participants.accepted', 'Accepted');
case 'declined': case 'declined':
return 'Declined'; return t('calendar.participants.declined', 'Declined');
case 'tentative': case 'tentative':
return 'Maybe'; return t('calendar.participants.tentative', 'Tentative');
case 'needs-action': case 'needs-action':
return 'No reply'; return t('calendar.participants.needs_action', 'Needs action');
default: default:
return status || ''; return status || '';
} }
+45 -34
View File
@@ -42,9 +42,12 @@ import {
getEventEndDate, getEventEndDate,
getEventStartDate, getEventStartDate,
getPrimaryCalendarId, getPrimaryCalendarId,
timePattern,
} from '../../lib/calendar-utils'; } from '../../lib/calendar-utils';
import { getEffectiveTimeZone } from '../../lib/calendar-timezone'; import { getEffectiveTimeZone } from '../../lib/calendar-timezone';
import { useSettingsStore } from '../../stores/settings-store';
import { canCreateEventsIn } from '../../lib/calendar-editability'; import { canCreateEventsIn } from '../../lib/calendar-editability';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { import {
alertsToReminders, alertsToReminders,
remindersToAlerts, remindersToAlerts,
@@ -65,13 +68,13 @@ import { RecurrenceEditor } from './RecurrenceEditor';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'; type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [ const RECURRENCE_OPTIONS: { value: RecurrenceOption; key: string; fallback: string }[] = [
{ value: 'none', label: 'Does not repeat' }, { value: 'none', key: 'calendar.recurrence.none', fallback: 'Does not repeat' },
{ value: 'daily', label: 'Daily' }, { value: 'daily', key: 'calendar.recurrence.daily', fallback: 'Daily' },
{ value: 'weekly', label: 'Weekly' }, { value: 'weekly', key: 'calendar.recurrence.weekly', fallback: 'Weekly' },
{ value: 'monthly', label: 'Monthly' }, { value: 'monthly', key: 'calendar.recurrence.monthly', fallback: 'Monthly' },
{ value: 'yearly', label: 'Yearly' }, { value: 'yearly', key: 'calendar.recurrence.yearly', fallback: 'Yearly' },
{ value: 'custom', label: 'Custom…' }, { value: 'custom', key: 'calendar.recurrence.custom', fallback: 'Custom…' },
]; ];
interface EventModalProps { interface EventModalProps {
@@ -172,6 +175,9 @@ export function EventModal({
}: EventModalProps) { }: EventModalProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
const timeFormat = useSettingsStore((s) => s.calendarTimeFormat);
const timeLabelPattern = timePattern(timeFormat);
const isEdit = !!event; const isEdit = !!event;
const [title, setTitle] = React.useState(''); const [title, setTitle] = React.useState('');
@@ -363,10 +369,13 @@ export function EventModal({
return true; return true;
}); });
}, [calendars, calendarId, event, isSubscriptionCalendar]); }, [calendars, calendarId, event, isSubscriptionCalendar]);
const recurrenceOption = RECURRENCE_OPTIONS.find((o) => o.value === recurrence);
const recurrenceLabel = const recurrenceLabel =
recurrence === 'custom' recurrence === 'custom'
? (customRule && buildRecurrenceSummary(customRule)) || 'Custom' ? (customRule && buildRecurrenceSummary(customRule)) || t('calendar.recurrence.custom', 'Custom…')
: RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None'; : recurrenceOption
? t(recurrenceOption.key, recurrenceOption.fallback)
: t('calendar.recurrence.none', 'Does not repeat');
const addReminder = (minutesBefore: number) => { const addReminder = (minutesBefore: number) => {
setReminderPickerOpen(false); setReminderPickerOpen(false);
@@ -395,7 +404,9 @@ export function EventModal({
<Pressable onPress={onClose} hitSlop={8} style={styles.headerBtn}> <Pressable onPress={onClose} hitSlop={8} style={styles.headerBtn}>
<X size={20} color={c.text} /> <X size={20} color={c.text} />
</Pressable> </Pressable>
<Text style={styles.headerTitle}>{isEdit ? 'Edit event' : 'New event'}</Text> <Text style={styles.headerTitle}>
{isEdit ? t('calendar.events.edit', 'Edit event') : t('calendar.events.new_event', 'New event')}
</Text>
<Button <Button
variant="default" variant="default"
size="sm" size="sm"
@@ -403,7 +414,7 @@ export function EventModal({
disabled={!title.trim() || !calendarId} disabled={!title.trim() || !calendarId}
loading={saving} loading={saving}
> >
Save {t('calendar.form.save', 'Save')}
</Button> </Button>
</View> </View>
@@ -415,14 +426,14 @@ export function EventModal({
<TextInput <TextInput
value={title} value={title}
onChangeText={setTitle} onChangeText={setTitle}
placeholder="Title" placeholder={t('calendar.form.title', 'Title')}
placeholderTextColor={c.textMuted} placeholderTextColor={c.textMuted}
style={styles.titleInput} style={styles.titleInput}
/> />
<Section <Section
icon={<CalendarIcon size={16} color={c.textMuted} />} icon={<CalendarIcon size={16} color={c.textMuted} />}
label="Calendar" label={t('calendar.form.calendar_select', 'Calendar')}
> >
<Pressable <Pressable
style={styles.fieldButton} style={styles.fieldButton}
@@ -438,7 +449,7 @@ export function EventModal({
/> />
)} )}
<Text style={styles.fieldText}> <Text style={styles.fieldText}>
{selectedCalendar?.name || 'Pick a calendar'} {selectedCalendar?.name || t('calendar.form.calendar_select', 'Calendar')}
</Text> </Text>
</View> </View>
</Pressable> </Pressable>
@@ -469,9 +480,9 @@ export function EventModal({
)} )}
</Section> </Section>
<Section icon={<Clock size={16} color={c.textMuted} />} label="Time"> <Section icon={<Clock size={16} color={c.textMuted} />} label={t('calendar.form.time', 'Time')}>
<View style={styles.allDayRow}> <View style={styles.allDayRow}>
<Text style={styles.fieldText}>All-day</Text> <Text style={styles.fieldText}>{t('calendar.form.all_day_event', 'All-day event')}</Text>
<Switch <Switch
value={allDay} value={allDay}
onValueChange={setAllDay} onValueChange={setAllDay}
@@ -481,47 +492,47 @@ export function EventModal({
</View> </View>
<View style={styles.dateRow}> <View style={styles.dateRow}>
<Text style={styles.dateLabel}>Starts</Text> <Text style={styles.dateLabel}>{t('calendar.form.start_date', 'Start date')}</Text>
<View style={styles.datePickers}> <View style={styles.datePickers}>
<Pressable <Pressable
style={styles.dateBtn} style={styles.dateBtn}
onPress={() => setShowStartDate(true)} onPress={() => setShowStartDate(true)}
> >
<Text style={styles.dateText}>{format(start, 'EEE, MMM d')}</Text> <Text style={styles.dateText}>{format(start, 'EEE, MMM d', { locale })}</Text>
</Pressable> </Pressable>
{!allDay && ( {!allDay && (
<Pressable <Pressable
style={styles.dateBtn} style={styles.dateBtn}
onPress={() => setShowStartTime(true)} onPress={() => setShowStartTime(true)}
> >
<Text style={styles.dateText}>{format(start, 'HH:mm')}</Text> <Text style={styles.dateText}>{format(start, timeLabelPattern, { locale })}</Text>
</Pressable> </Pressable>
)} )}
</View> </View>
</View> </View>
<View style={styles.dateRow}> <View style={styles.dateRow}>
<Text style={styles.dateLabel}>Ends</Text> <Text style={styles.dateLabel}>{t('calendar.form.end_date', 'End date')}</Text>
<View style={styles.datePickers}> <View style={styles.datePickers}>
<Pressable <Pressable
style={styles.dateBtn} style={styles.dateBtn}
onPress={() => setShowEndDate(true)} onPress={() => setShowEndDate(true)}
> >
<Text style={styles.dateText}>{format(end, 'EEE, MMM d')}</Text> <Text style={styles.dateText}>{format(end, 'EEE, MMM d', { locale })}</Text>
</Pressable> </Pressable>
{!allDay && ( {!allDay && (
<Pressable <Pressable
style={styles.dateBtn} style={styles.dateBtn}
onPress={() => setShowEndTime(true)} onPress={() => setShowEndTime(true)}
> >
<Text style={styles.dateText}>{format(end, 'HH:mm')}</Text> <Text style={styles.dateText}>{format(end, timeLabelPattern, { locale })}</Text>
</Pressable> </Pressable>
)} )}
</View> </View>
</View> </View>
</Section> </Section>
<Section icon={<Repeat size={16} color={c.textMuted} />} label="Repeat"> <Section icon={<Repeat size={16} color={c.textMuted} />} label={t('calendar.recurrence.title', 'Recurrence')}>
<Pressable <Pressable
style={styles.fieldButton} style={styles.fieldButton}
onPress={() => setRecurrenceOpen((v) => !v)} onPress={() => setRecurrenceOpen((v) => !v)}
@@ -548,7 +559,7 @@ export function EventModal({
setRecurrenceEditorOpen(false); setRecurrenceEditorOpen(false);
}} }}
> >
<Text style={styles.popoverRowText}>{opt.label}</Text> <Text style={styles.popoverRowText}>{t(opt.key, opt.fallback)}</Text>
</Pressable> </Pressable>
))} ))}
</View> </View>
@@ -567,12 +578,12 @@ export function EventModal({
)} )}
{recurrence === 'custom' && customRule && !recurrenceEditorOpen && ( {recurrence === 'custom' && customRule && !recurrenceEditorOpen && (
<Pressable style={styles.addRow} onPress={() => setRecurrenceEditorOpen(true)}> <Pressable style={styles.addRow} onPress={() => setRecurrenceEditorOpen(true)}>
<Text style={styles.addRowText}>Edit custom recurrence</Text> <Text style={styles.addRowText}>{t('calendar.recurrence.edit_custom', 'Edit custom recurrence')}</Text>
</Pressable> </Pressable>
)} )}
</Section> </Section>
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminders"> <Section icon={<Bell size={16} color={c.textMuted} />} label={t('calendar.alerts.title', 'Reminders')}>
{reminders.length > 0 && ( {reminders.length > 0 && (
<View style={{ gap: spacing.xs }}> <View style={{ gap: spacing.xs }}>
{reminders.map((r) => ( {reminders.map((r) => (
@@ -596,7 +607,7 @@ export function EventModal({
> >
<Plus size={16} color={c.primary} /> <Plus size={16} color={c.primary} />
<Text style={styles.addRowText}> <Text style={styles.addRowText}>
{reminders.length === 0 ? 'Add reminder' : 'Add another reminder'} {t('calendar.alerts.add', 'Add reminder')}
</Text> </Text>
</Pressable> </Pressable>
)} )}
@@ -615,17 +626,17 @@ export function EventModal({
)} )}
</Section> </Section>
<Section icon={<MapPin size={16} color={c.textMuted} />} label="Location"> <Section icon={<MapPin size={16} color={c.textMuted} />} label={t('calendar.form.location', 'Location')}>
<TextInput <TextInput
value={location} value={location}
onChangeText={setLocation} onChangeText={setLocation}
placeholder="Place or address" placeholder={t('calendar.form.location_placeholder', 'Place or address')}
placeholderTextColor={c.textMuted} placeholderTextColor={c.textMuted}
style={styles.fieldButton} style={styles.fieldButton}
/> />
</Section> </Section>
<Section icon={<Video size={16} color={c.textMuted} />} label="Video call"> <Section icon={<Video size={16} color={c.textMuted} />} label={t('calendar.form.meeting_link', 'Meeting link')}>
<TextInput <TextInput
value={videoUrl} value={videoUrl}
onChangeText={setVideoUrl} onChangeText={setVideoUrl}
@@ -640,12 +651,12 @@ export function EventModal({
<Section <Section
icon={<AlignLeft size={16} color={c.textMuted} />} icon={<AlignLeft size={16} color={c.textMuted} />}
label="Description" label={t('calendar.form.description', 'Description')}
> >
<TextInput <TextInput
value={description} value={description}
onChangeText={setDescription} onChangeText={setDescription}
placeholder="Notes, agenda…" placeholder={t('calendar.form.description_placeholder', 'Notes, agenda…')}
placeholderTextColor={c.textMuted} placeholderTextColor={c.textMuted}
multiline multiline
style={styles.descriptionInput} style={styles.descriptionInput}
@@ -654,7 +665,7 @@ export function EventModal({
<Section <Section
icon={<Users size={16} color={c.textMuted} />} icon={<Users size={16} color={c.textMuted} />}
label="Participants" label={t('calendar.participants.title', 'Participants')}
> >
<ParticipantInput <ParticipantInput
attendees={attendees} attendees={attendees}
@@ -674,7 +685,7 @@ export function EventModal({
onPress={handleDelete} onPress={handleDelete}
> >
<Trash2 size={16} color={c.error} /> <Trash2 size={16} color={c.error} />
<Text style={styles.deleteBtnText}>Delete event</Text> <Text style={styles.deleteBtnText}>{t('calendar.events.delete', 'Delete event')}</Text>
</Pressable> </Pressable>
)} )}
</ScrollView> </ScrollView>
+67 -11
View File
@@ -5,6 +5,7 @@ import {
endOfMonth, endOfMonth,
endOfWeek, endOfWeek,
format, format,
getISOWeek,
getWeek, getWeek,
isSameDay, isSameDay,
isSameMonth, isSameMonth,
@@ -19,11 +20,14 @@ import {
buildEventDayIndex, buildEventDayIndex,
eventsOnDayFromIndex, eventsOnDayFromIndex,
getEventColor, getEventColor,
getEventStartDate,
timePattern,
type EventDayIndex, type EventDayIndex,
type TimeFormat,
} from '../../lib/calendar-utils'; } from '../../lib/calendar-utils';
import { useCalendarLocale } from '../../lib/calendar-locale';
const SUN_FIRST = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; type WeekStart = 0 | 1 | 6;
const MON_FIRST = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
interface MonthViewProps { interface MonthViewProps {
currentDate: Date; currentDate: Date;
@@ -31,12 +35,23 @@ interface MonthViewProps {
events: CalendarEvent[]; events: CalendarEvent[];
calendars: Calendar[]; calendars: Calendar[];
eventsByDay?: EventDayIndex; eventsByDay?: EventDayIndex;
weekStartsOn?: 0 | 1; weekStartsOn?: WeekStart;
showWeekNumbers?: boolean; showWeekNumbers?: boolean;
// Render compact event chips (title + start time) instead of dots (#666).
showTimeInMonthView?: boolean;
timeFormat?: TimeFormat;
onSelectDate: (date: Date) => void; onSelectDate: (date: Date) => void;
onLongPressDate?: (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({ function MonthViewInner({
currentDate, currentDate,
selectedDate, selectedDate,
@@ -45,12 +60,18 @@ function MonthViewInner({
eventsByDay, eventsByDay,
weekStartsOn = 0, weekStartsOn = 0,
showWeekNumbers = false, showWeekNumbers = false,
showTimeInMonthView = false,
timeFormat,
onSelectDate, onSelectDate,
onLongPressDate, onLongPressDate,
}: MonthViewProps) { }: MonthViewProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); 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( const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events), () => eventsByDay ?? buildEventDayIndex(events),
[eventsByDay, events], [eventsByDay, events],
@@ -82,13 +103,15 @@ function MonthViewInner({
const selected = isSameDay(d, selectedDate); const selected = isSameDay(d, selectedDate);
const dayEvents = eventsOnDayFromIndex(index, d); const dayEvents = eventsOnDayFromIndex(index, d);
const MAX_DOTS = dayEvents.length > 3 ? 2 : 3; const MAX_DOTS = dayEvents.length > 3 ? 2 : 3;
const visibleDots = dayEvents.slice(0, MAX_DOTS); const MAX_CHIPS = dayEvents.length > 2 ? 1 : 2;
const overflow = Math.max(0, dayEvents.length - MAX_DOTS); const maxVisible = showTimeInMonthView ? MAX_CHIPS : MAX_DOTS;
const visible = dayEvents.slice(0, maxVisible);
const overflow = Math.max(0, dayEvents.length - maxVisible);
return ( return (
<Pressable <Pressable
key={key} key={key}
style={[styles.dayCell, flexLayout && styles.dayCellFlex]} style={[styles.dayCell, flexLayout && styles.dayCellFlex, showTimeInMonthView && styles.dayCellTall]}
onPress={() => onSelectDate(d)} onPress={() => onSelectDate(d)}
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined} onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
> >
@@ -106,9 +129,9 @@ function MonthViewInner({
{format(d, 'd')} {format(d, 'd')}
</Text> </Text>
</View> </View>
{dayEvents.length > 0 && ( {dayEvents.length > 0 && !showTimeInMonthView && (
<View style={styles.dotsRow}> <View style={styles.dotsRow}>
{visibleDots.map((event, idx) => ( {visible.map((event, idx) => (
<View <View
key={`${event.id}-${idx}`} key={`${event.id}-${idx}`}
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]} style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
@@ -119,6 +142,29 @@ function MonthViewInner({
)} )}
</View> </View>
)} )}
{dayEvents.length > 0 && showTimeInMonthView && (
<View style={styles.chipsCol}>
{visible.map((event, idx) => {
const color = getEventColor(event, calendars);
const cancelled = event.status === 'cancelled';
return (
<View key={`${event.id}-${idx}`} style={[styles.chip, { backgroundColor: color }]}>
<Text
style={[styles.chipText, cancelled && styles.chipTextCancelled]}
numberOfLines={1}
>
{event.showWithoutTime
? (event.title || '')
: `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ${event.title || ''}`}
</Text>
</View>
);
})}
{overflow > 0 && (
<Text style={styles.overflowText}>+{overflow}</Text>
)}
</View>
)}
</Pressable> </Pressable>
); );
}; };
@@ -134,7 +180,7 @@ function MonthViewInner({
{showWeekNumbers ? ( {showWeekNumbers ? (
rows.map((row, rowIdx) => ( rows.map((row, rowIdx) => (
<View key={rowIdx} style={styles.weekRow}> <View key={rowIdx} style={styles.weekRow}>
<Text style={styles.weekNumberCell}>{getWeek(row[0], { weekStartsOn })}</Text> <Text style={styles.weekNumberCell}>{weekNumberFor(row[0], weekStartsOn)}</Text>
{row.map((d, i) => renderDay(d, `${rowIdx}-${i}`, true))} {row.map((d, i) => renderDay(d, `${rowIdx}-${i}`, true))}
</View> </View>
)) ))
@@ -160,7 +206,7 @@ function makeStyles(c: ThemePalette) {
color: c.textMuted, color: c.textMuted,
}, },
daysGrid: { flexDirection: 'row', flexWrap: 'wrap' }, daysGrid: { flexDirection: 'row', flexWrap: 'wrap' },
weekRow: { flexDirection: 'row', alignItems: 'center' }, weekRow: { flexDirection: 'row', alignItems: 'flex-start' },
weekNumberLabel: { weekNumberLabel: {
width: 24, width: 24,
textAlign: 'center', textAlign: 'center',
@@ -176,6 +222,7 @@ function makeStyles(c: ThemePalette) {
}, },
dayCell: { width: '14.28%', alignItems: 'center', paddingVertical: 4 }, dayCell: { width: '14.28%', alignItems: 'center', paddingVertical: 4 },
dayCellFlex: { width: undefined, flex: 1 }, dayCellFlex: { width: undefined, flex: 1 },
dayCellTall: { minHeight: 74, paddingHorizontal: 1 },
dayNumber: { dayNumber: {
width: 36, width: 36,
height: 36, height: 36,
@@ -204,11 +251,20 @@ function makeStyles(c: ThemePalette) {
height: componentSizes.eventDot, height: componentSizes.eventDot,
borderRadius: componentSizes.eventDot / 2, 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: { overflowText: {
color: c.textMuted, color: c.textMuted,
fontSize: 9, fontSize: 9,
lineHeight: 10, lineHeight: 10,
marginLeft: 1, marginLeft: 1,
textAlign: 'center',
}, },
}); });
} }
@@ -3,6 +3,7 @@ import { Modal, View, Text, Pressable, StyleSheet } from 'react-native';
import { Repeat, Trash2 } from 'lucide-react-native'; import { Repeat, Trash2 } from 'lucide-react-native';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { Button } from '..'; import { Button } from '..';
export type RecurrenceEditScope = 'this' | 'this_and_future' | 'all'; export type RecurrenceEditScope = 'this' | 'this_and_future' | 'all';
@@ -14,10 +15,10 @@ interface RecurrenceScopeDialogProps {
onClose: () => void; onClose: () => void;
} }
const OPTIONS: { value: RecurrenceEditScope; label: string }[] = [ const OPTIONS: { value: RecurrenceEditScope; key: string; fallback: string }[] = [
{ value: 'this', label: 'This event' }, { value: 'this', key: 'calendar.recurrence_scope.this_event', fallback: 'This event only' },
{ value: 'this_and_future', label: 'This and following events' }, { value: 'this_and_future', key: 'calendar.recurrence_scope.this_and_future', fallback: 'This and following events' },
{ value: 'all', label: 'All events' }, { value: 'all', key: 'calendar.recurrence_scope.all_events', fallback: 'All events' },
]; ];
export function RecurrenceScopeDialog({ export function RecurrenceScopeDialog({
@@ -28,6 +29,7 @@ export function RecurrenceScopeDialog({
}: RecurrenceScopeDialogProps) { }: RecurrenceScopeDialogProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [selected, setSelected] = React.useState<RecurrenceEditScope>('this'); const [selected, setSelected] = React.useState<RecurrenceEditScope>('this');
const isDelete = actionType === 'delete'; const isDelete = actionType === 'delete';
@@ -49,10 +51,15 @@ export function RecurrenceScopeDialog({
</View> </View>
<View style={styles.headerText}> <View style={styles.headerText}>
<Text style={styles.title}> <Text style={styles.title}>
{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')}
</Text> </Text>
<Text style={styles.description}> <Text style={styles.description}>
Choose which occurrences to {isDelete ? 'delete' : 'change'}. {t(
'calendar.recurrence_scope.description',
'This is a recurring event. Which events would you like to modify?',
)}
</Text> </Text>
</View> </View>
</View> </View>
@@ -69,7 +76,7 @@ export function RecurrenceScopeDialog({
<View style={[styles.radio, active && styles.radioActive]}> <View style={[styles.radio, active && styles.radioActive]}>
{active && <View style={styles.radioDot} />} {active && <View style={styles.radioDot} />}
</View> </View>
<Text style={styles.optionLabel}>{opt.label}</Text> <Text style={styles.optionLabel}>{t(opt.key, opt.fallback)}</Text>
</Pressable> </Pressable>
); );
})} })}
@@ -77,14 +84,16 @@ export function RecurrenceScopeDialog({
<View style={styles.footer}> <View style={styles.footer}>
<Button variant="outline" size="sm" onPress={onClose}> <Button variant="outline" size="sm" onPress={onClose}>
Cancel {t('calendar.recurrence_scope.cancel', 'Cancel')}
</Button> </Button>
<Button <Button
variant={isDelete ? 'destructive' : 'default'} variant={isDelete ? 'destructive' : 'default'}
size="sm" size="sm"
onPress={() => onSelect(selected)} onPress={() => onSelect(selected)}
> >
{isDelete ? 'Delete' : 'Save'} {isDelete
? t('calendar.recurrence_scope.delete', 'Delete')
: t('calendar.recurrence_scope.save', 'Save')}
</Button> </Button>
</View> </View>
</View> </View>
+14 -5
View File
@@ -10,6 +10,7 @@ import { addDays, format, isSameDay, isToday, startOfWeek } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types'; import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { import {
buildEventDayIndex, buildEventDayIndex,
buildTimedFullDayWeekSegments, buildTimedFullDayWeekSegments,
@@ -37,7 +38,7 @@ interface WeekViewProps {
onSelectDate?: (date: Date) => void; onSelectDate?: (date: Date) => void;
onSelectEvent?: (event: CalendarEvent) => void; onSelectEvent?: (event: CalendarEvent) => void;
onCreateAtTime?: (date: Date) => void; onCreateAtTime?: (date: Date) => void;
weekStartsOn?: 0 | 1; weekStartsOn?: 0 | 1 | 6;
timeFormat?: '12h' | '24h'; timeFormat?: '12h' | '24h';
} }
@@ -54,6 +55,7 @@ function WeekViewInner({
}: WeekViewProps) { }: WeekViewProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale } = useCalendarLocale();
const scrollRef = React.useRef<ScrollView>(null); const scrollRef = React.useRef<ScrollView>(null);
const index = React.useMemo( const index = React.useMemo(
@@ -136,7 +138,7 @@ function WeekViewInner({
style={styles.dayHeaderCell} style={styles.dayHeaderCell}
onPress={() => onSelectDate?.(day)} onPress={() => onSelectDate?.(day)}
> >
<Text style={styles.dayHeaderWeekday}>{format(day, 'EEE')}</Text> <Text style={styles.dayHeaderWeekday}>{format(day, 'EEE', { locale })}</Text>
<View <View
style={[ style={[
styles.dayHeaderNumber, styles.dayHeaderNumber,
@@ -280,7 +282,7 @@ function WeekViewInner({
</Text> </Text>
{height > 32 && ( {height > 32 && (
<Text style={styles.eventBlockTime} numberOfLines={1}> <Text style={styles.eventBlockTime} numberOfLines={1}>
{minutesToTimeLabel(startMinutes)} {minutesToTimeLabel(startMinutes, timeFormat)}
</Text> </Text>
)} )}
</Pressable> </Pressable>
@@ -312,10 +314,17 @@ function WeekViewInner({
export const WeekView = React.memo(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 h = Math.floor(minutes / 60);
const m = 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) { function makeStyles(c: ThemePalette) {
+87 -42
View File
@@ -5,10 +5,32 @@ import {
type CalendarView, type CalendarView,
type FirstDayOfWeek, type FirstDayOfWeek,
type TimeFormat, type TimeFormat,
type CalendarHoverPreview,
} from '../../stores/settings-store'; } 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() { export function CalendarSettings() {
const t = useLocaleStore((s) => s.t);
const hydrated = useSettingsStore((s) => s.hydrated); const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate); const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting); const update = useSettingsStore((s) => s.updateSetting);
@@ -16,9 +38,9 @@ export function CalendarSettings() {
const viewMode = useSettingsStore((s) => s.calendarDefaultView); const viewMode = useSettingsStore((s) => s.calendarDefaultView);
const firstDay = useSettingsStore((s) => s.calendarFirstDayOfWeek); const firstDay = useSettingsStore((s) => s.calendarFirstDayOfWeek);
const timeFormat = useSettingsStore((s) => s.calendarTimeFormat); const timeFormat = useSettingsStore((s) => s.calendarTimeFormat);
const timeZone = useSettingsStore((s) => s.calendarTimeZone);
const showTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth); const showTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth);
const showWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers); const showWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
const hoverPreview = useSettingsStore((s) => s.calendarHoverPreview);
const birthdayCal = useSettingsStore((s) => s.showBirthdayCalendar); const birthdayCal = useSettingsStore((s) => s.showBirthdayCalendar);
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks); const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar); const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar);
@@ -27,46 +49,80 @@ export function CalendarSettings() {
if (!hydrated) void hydrate(); if (!hydrated) void hydrate();
}, [hydrated, hydrate]); }, [hydrated, hydrate]);
const deviceZone = getDeviceTimeZone();
const timeZoneOptions = React.useMemo(() => {
const zones = new Set<string>([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 ( return (
<SettingsSection title="Calendar"> <SettingsSection title={t('calendar.settings.title', 'Calendar')}>
<SettingItem label="Default view"> <SettingItem label={t('calendar.settings.default_view', 'Default view')}>
<Select <Select
value={viewMode} value={effectiveView}
onChange={(v) => update('calendarDefaultView', v as CalendarView)} onChange={(v) => update('calendarDefaultView', v as CalendarView)}
options={[ options={[
{ value: 'month', label: 'Month' }, { value: 'month', label: t('calendar.views.month', 'Month') },
{ value: 'week', label: 'Week' }, { value: 'week', label: t('calendar.views.week', 'Week') },
{ value: 'day', label: 'Day' }, { value: 'agenda', label: t('calendar.views.agenda', 'Agenda') },
{ value: 'agenda', label: 'Agenda' },
]} ]}
/> />
</SettingItem> </SettingItem>
<SettingItem label="Week starts on"> <SettingItem label={t('calendar.settings.week_starts_on', 'Week starts on')}>
<Select <Select
value={String(firstDay)} value={String(firstDay)}
onChange={(v) => update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)} onChange={(v) => update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)}
options={[ options={[
{ value: '1', label: 'Monday' }, { value: '1', label: t('calendar.days.monday', 'Monday') },
{ value: '0', label: 'Sunday' }, { value: '6', label: t('calendar.days.saturday', 'Saturday') },
{ value: '0', label: t('calendar.days.sunday', 'Sunday') },
]} ]}
/> />
</SettingItem> </SettingItem>
<SettingItem label="Time format"> <SettingItem label={t('calendar.settings.time_format', 'Time format')}>
<RadioGroup <RadioGroup
value={timeFormat} value={timeFormat}
onChange={(v) => update('calendarTimeFormat', v as TimeFormat)} onChange={(v) => update('calendarTimeFormat', v as TimeFormat)}
options={[ options={[
{ value: '12h', label: '12-hour' }, { value: '12h', label: t('calendar.settings.time_format_12h', '12-hour') },
{ value: '24h', label: '24-hour' }, { value: '24h', label: t('calendar.settings.time_format_24h', '24-hour') },
]} ]}
/> />
</SettingItem> </SettingItem>
<SettingItem <SettingItem
label="Show time in month view" label={t('calendar.settings.time_zone', 'Time zone')}
description="Show start times next to events in the month grid." description={t(
'calendar.settings.time_zone_desc',
'Zone used for new events and for interpreting the calendar. "Device" follows the phone.',
)}
>
<Select
value={timeZone || AUTO_TIME_ZONE}
onChange={(v) => update('calendarTimeZone', v)}
options={timeZoneOptions}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_time_in_month_view', 'Show time in month view')}
description={t(
'calendar.settings.show_time_in_month_view_desc',
'Display event times in the month calendar view. On small screens this shows full event entries instead of dots.',
)}
> >
<ToggleSwitch <ToggleSwitch
checked={showTimeInMonth} checked={showTimeInMonth}
@@ -75,8 +131,8 @@ export function CalendarSettings() {
</SettingItem> </SettingItem>
<SettingItem <SettingItem
label="Show week numbers" label={t('calendar.settings.show_week_numbers', 'Show week numbers')}
description="Display ISO week numbers in month and week views." description={t('calendar.settings.show_week_numbers_mobile_desc', 'Display week numbers in the month view.')}
> >
<ToggleSwitch <ToggleSwitch
checked={showWeekNumbers} checked={showWeekNumbers}
@@ -85,25 +141,11 @@ export function CalendarSettings() {
</SettingItem> </SettingItem>
<SettingItem <SettingItem
label="Hover preview" label={t('calendar.settings.show_birthday_calendar', 'Contact birthday calendar')}
description="Show event details on hover or tap-and-hold." description={t(
> 'calendar.settings.show_birthday_calendar_desc',
<Select 'Show a virtual calendar with birthdays from your contacts',
value={hoverPreview} )}
onChange={(v) => 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' },
]}
/>
</SettingItem>
<SettingItem
label="Show birthday calendar"
description="Automatically include birthdays from your contacts."
> >
<ToggleSwitch <ToggleSwitch
checked={birthdayCal} checked={birthdayCal}
@@ -112,8 +154,8 @@ export function CalendarSettings() {
</SettingItem> </SettingItem>
<SettingItem <SettingItem
label="Enable tasks" label={t('calendar.settings.enable_tasks', 'Enable tasks')}
description="Show task management alongside events." description={t('calendar.settings.enable_tasks_desc', 'Show a tasks view in the calendar for managing to-dos')}
> >
<ToggleSwitch <ToggleSwitch
checked={tasksEnabled} checked={tasksEnabled}
@@ -123,8 +165,11 @@ export function CalendarSettings() {
{tasksEnabled && ( {tasksEnabled && (
<SettingItem <SettingItem
label="Show tasks on calendar" label={t('calendar.settings.show_tasks_on_calendar', 'Show tasks on calendar')}
description="Overlay tasks with due dates on the calendar." description={t(
'calendar.settings.show_tasks_on_calendar_desc',
'Display task chips on the day and week calendar views',
)}
> >
<ToggleSwitch <ToggleSwitch
checked={showTasksOnCal} checked={showTasksOnCal}
+24
View File
@@ -0,0 +1,24 @@
import type { Locale } from 'date-fns';
import {
ar, ca, cs, da, de, enUS, es, faIR, fr, he, hu, it, ja, ko, lv, mn, nb, nl, pl, pt, ro, ru, sk, tr, uk, zhCN, zhTW,
} from 'date-fns/locale';
import { useLocaleStore } from '../stores/locale-store';
import type { LocaleCode } from '../i18n';
// date-fns locale for each UI language so month/day names, "EEE, MMM d"
// headers and the like render in the user's language (the webmail localizes
// them through next-intl / useFormatEventDate).
const DATE_FNS_LOCALES: Record<LocaleCode, Locale> = {
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 };
}
+4 -2
View File
@@ -472,9 +472,11 @@ export function getEventColor(
* owning JMAP account + the calendar id so it stays unique across accounts. * owning JMAP account + the calendar id so it stays unique across accounts.
*/ */
export function sharedCalendarColorKey( export function sharedCalendarColorKey(
cal: Pick<Calendar, 'id' | 'accountId'>, cal: Pick<Calendar, 'id' | 'accountId' | 'originalId'>,
): string { ): 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}`;
} }
/** /**
+28 -12
View File
@@ -33,7 +33,9 @@ import {
subMonths, subMonths,
addWeeks, addWeeks,
subWeeks, subWeeks,
type Locale,
} from 'date-fns'; } from 'date-fns';
import { useCalendarLocale } from '../lib/calendar-locale';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors'; import { useColors } from '../theme/colors';
import { Button } from '../components'; import { Button } from '../components';
@@ -89,10 +91,12 @@ type PendingAction =
const AGENDA_DAYS = 30; const AGENDA_DAYS = 30;
const RANGE_BUFFER_DAYS = 14; const RANGE_BUFFER_DAYS = 14;
type WeekStart = 0 | 1 | 6;
function rangeForView( function rangeForView(
viewMode: ViewMode, viewMode: ViewMode,
currentDate: Date, currentDate: Date,
weekStartsOn: 0 | 1, weekStartsOn: WeekStart,
): { after: Date; before: Date } { ): { after: Date; before: Date } {
if (viewMode === 'month') { if (viewMode === 'month') {
const start = startOfWeek(startOfMonth(currentDate), { weekStartsOn }); const start = startOfWeek(startOfMonth(currentDate), { weekStartsOn });
@@ -113,18 +117,19 @@ function rangeForView(
function headerTitle( function headerTitle(
viewMode: ViewMode, viewMode: ViewMode,
currentDate: Date, currentDate: Date,
weekStartsOn: 0 | 1, weekStartsOn: WeekStart,
locale: Locale,
): string { ): string {
if (viewMode === 'month') return format(currentDate, 'MMMM yyyy'); if (viewMode === 'month') return format(currentDate, 'MMMM yyyy', { locale });
if (viewMode === 'week') { if (viewMode === 'week') {
const start = startOfWeek(currentDate, { weekStartsOn }); const start = startOfWeek(currentDate, { weekStartsOn });
const end = endOfWeek(currentDate, { weekStartsOn }); const end = endOfWeek(currentDate, { weekStartsOn });
if (start.getMonth() === end.getMonth()) { 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() { export default function CalendarScreen() {
@@ -132,7 +137,9 @@ export default function CalendarScreen() {
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t); const t = useLocaleStore((s) => s.t);
const currentUserEmails = useUserCalendarAddresses(); const currentUserEmails = useUserCalendarAddresses();
const { locale } = useCalendarLocale();
const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView); const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView);
const calendarShowTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth);
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek); const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers); const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat); const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat);
@@ -626,9 +633,13 @@ export default function CalendarScreen() {
<Menu size={20} color={c.text} /> <Menu size={20} color={c.text} />
</Pressable> </Pressable>
<View style={styles.headerLeft}> <View style={styles.headerLeft}>
<Text style={styles.headerTitle}>{headerTitle(viewMode, currentDate, calendarFirstDayOfWeek)}</Text> <Text style={styles.headerTitle}>
{headerTitle(viewMode, currentDate, calendarFirstDayOfWeek, locale)}
</Text>
<Text style={styles.headerSubtitle}> <Text style={styles.headerSubtitle}>
{isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')} {isSelectedToday
? t('calendar.views.today', 'Today')
: format(selectedDate, 'EEE, MMM d', { locale })}
</Text> </Text>
</View> </View>
<View style={styles.headerActions}> <View style={styles.headerActions}>
@@ -668,7 +679,7 @@ export default function CalendarScreen() {
<ChevronLeft size={20} color={c.text} /> <ChevronLeft size={20} color={c.text} />
</Pressable> </Pressable>
<Button variant="outline" size="sm" onPress={goToday}> <Button variant="outline" size="sm" onPress={goToday}>
Today {t('calendar.views.today', 'Today')}
</Button> </Button>
<Pressable onPress={goNext} style={styles.navBtn} hitSlop={8}> <Pressable onPress={goNext} style={styles.navBtn} hitSlop={8}>
<ChevronRight size={20} color={c.text} /> <ChevronRight size={20} color={c.text} />
@@ -691,6 +702,8 @@ export default function CalendarScreen() {
calendars={calendars} calendars={calendars}
weekStartsOn={calendarFirstDayOfWeek} weekStartsOn={calendarFirstDayOfWeek}
showWeekNumbers={calendarShowWeekNumbers} showWeekNumbers={calendarShowWeekNumbers}
showTimeInMonthView={calendarShowTimeInMonth}
timeFormat={calendarTimeFormat}
onSelectDate={handleSelectDate} onSelectDate={handleSelectDate}
onLongPressDate={openCreate} onLongPressDate={openCreate}
/> />
@@ -724,7 +737,9 @@ export default function CalendarScreen() {
<View style={styles.dayDetail}> <View style={styles.dayDetail}>
<View style={styles.dayDetailHeader}> <View style={styles.dayDetailHeader}>
<Text style={styles.dayDetailTitle}> <Text style={styles.dayDetailTitle}>
{isSelectedToday ? "Today's Events" : format(selectedDate, 'EEEE, MMMM d')} {isSelectedToday
? t('calendar.events.today_header', 'Today')
: format(selectedDate, 'EEEE, MMMM d', { locale })}
</Text> </Text>
{loading && <ActivityIndicator size="small" color={c.textMuted} />} {loading && <ActivityIndicator size="small" color={c.textMuted} />}
</View> </View>
@@ -870,6 +885,7 @@ function DayEventList({
}) { }) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const dayEvents = React.useMemo( const dayEvents = React.useMemo(
() => eventsOnDayFromIndex(eventsByDay, date), () => eventsOnDayFromIndex(eventsByDay, date),
[eventsByDay, date], [eventsByDay, date],
@@ -883,8 +899,8 @@ function DayEventList({
} }
> >
<CalendarDays size={32} color={c.surfaceActive} /> <CalendarDays size={32} color={c.surfaceActive} />
<Text style={styles.emptyTitle}>No events</Text> <Text style={styles.emptyTitle}>{t('calendar.events.no_events', 'No events')}</Text>
<Text style={styles.emptySubtitle}>Tap + to create one</Text> <Text style={styles.emptySubtitle}>{t('calendar.events.tap_to_create', 'Tap + to create one')}</Text>
</ScrollView> </ScrollView>
); );
} }
+2 -1
View File
@@ -77,7 +77,8 @@ export function normalizeBottomQuickActions(value: unknown): QuickAction[] {
} }
export type ArchiveMode = 'single' | 'year' | 'month'; export type ArchiveMode = 'single' | 'year' | 'month';
export type CalendarView = 'month' | 'week' | 'day' | 'agenda'; 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'; export type TimeFormat = '12h' | '24h';
// Email-list date rendering style. Mirrors the webmail `dateFormat` setting: // Email-list date rendering style. Mirrors the webmail `dateFormat` setting:
// smart — locale-aware, age-bucketed (today→time, this week→weekday+time, older→date) // smart — locale-aware, age-bucketed (today→time, this week→weekday+time, older→date)