mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: localize the calendar, add Saturday week start, ISO week numbers, month chips and 12h labels (#666)
This commit is contained in:
@@ -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`.
|
||||
|
||||
+70
-4
@@ -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": {
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.empty}>
|
||||
<CalendarDays size={40} color={c.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>No upcoming events</Text>
|
||||
<Text style={styles.emptySubtitle}>The next {daysAhead} days are clear.</Text>
|
||||
<Text style={styles.emptyTitle}>{t('calendar.agenda.no_upcoming', 'No upcoming events')}</Text>
|
||||
<Text style={styles.emptySubtitle}>
|
||||
{t('calendar.agenda.days_clear', 'The next {count} days are clear.').replace('{count}', String(daysAhead))}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -107,7 +115,7 @@ export function AgendaView({
|
||||
<Text style={[styles.sectionTitle, today && styles.sectionTitleToday]}>
|
||||
{section.title}
|
||||
</Text>
|
||||
<Text style={styles.sectionSub}>{format(section.date, 'MMM d')}</Text>
|
||||
<Text style={styles.sectionSub}>{format(section.date, 'MMM d', { locale })}</Text>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
@@ -119,7 +127,7 @@ export function AgendaView({
|
||||
renderSectionFooter={({ section }) =>
|
||||
section.data.length === 0 ? (
|
||||
<View style={styles.emptyDayWrap}>
|
||||
<Text style={styles.emptyDayText}>No events</Text>
|
||||
<Text style={styles.emptyDayText}>{t('calendar.events.no_events', 'No events')}</Text>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -145,24 +147,24 @@ export function CalendarSidebarDrawer({
|
||||
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>Calendars</Text>
|
||||
<Text style={styles.headerTitle}>{t('calendar.my_calendars', 'Calendars')}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
|
||||
{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 && (
|
||||
<Section title="My calendars" calendars={myCalendars} {...sectionProps} />
|
||||
<Section title={t('calendar.drawer.my_calendars', 'My calendars')} calendars={myCalendars} {...sectionProps} />
|
||||
)}
|
||||
|
||||
{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 && (
|
||||
<Section title="Subscribed" calendars={subscribed} {...sectionProps} />
|
||||
<Section title={t('calendar.drawer.subscribed', 'Subscribed')} calendars={subscribed} {...sectionProps} />
|
||||
)}
|
||||
|
||||
{(onImport || onManageSubscriptions || onCreate) && (
|
||||
@@ -173,7 +175,7 @@ export function CalendarSidebarDrawer({
|
||||
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{onManageSubscriptions && (
|
||||
@@ -182,7 +184,7 @@ export function CalendarSidebarDrawer({
|
||||
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{onImport && (
|
||||
@@ -191,7 +193,7 @@ export function CalendarSidebarDrawer({
|
||||
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</View>
|
||||
@@ -236,6 +238,7 @@ function Section({
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
@@ -291,7 +294,7 @@ function Section({
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{canRename && (
|
||||
@@ -300,7 +303,7 @@ function Section({
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{canRecolor && (
|
||||
@@ -329,7 +332,7 @@ function Section({
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
@@ -340,7 +343,7 @@ function Section({
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Share2 size={16} color={c.textSecondary} />
|
||||
<Text style={styles.panelRowText}>Share…</Text>
|
||||
<Text style={styles.panelRowText}>{t('calendar.management.share', 'Share calendar')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{canClear && (
|
||||
@@ -349,7 +352,7 @@ function Section({
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{canDelete && (
|
||||
@@ -358,7 +361,7 @@ function Section({
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
@@ -47,12 +48,17 @@ export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }
|
||||
<View style={[styles.colorBar, { backgroundColor: color }]} />
|
||||
<View style={styles.body}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{event.title || 'Untitled'}
|
||||
<Text style={[styles.title, cancelled && styles.titleCancelled]} numberOfLines={1}>
|
||||
{event.title || t('calendar.events.no_title', '(No title)')}
|
||||
</Text>
|
||||
{event.showWithoutTime && (
|
||||
<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>
|
||||
@@ -62,18 +68,20 @@ export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }
|
||||
<Text style={styles.detailText}>{time}</Text>
|
||||
</View>
|
||||
)}
|
||||
{event.description ? (
|
||||
{location ? (
|
||||
<View style={styles.detailRow}>
|
||||
<MapPin size={12} color={c.textMuted} />
|
||||
<Text style={styles.detailText} numberOfLines={1}>
|
||||
{event.description}
|
||||
{location}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{count > 0 && (
|
||||
<View style={styles.detailRow}>
|
||||
<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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
yearly: 'Yearly',
|
||||
const freq = rule.frequency.toLowerCase();
|
||||
const unitKey: Record<string, [string, string]> = {
|
||||
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({
|
||||
<View style={[styles.colorBar, { backgroundColor: color }]} />
|
||||
<View style={styles.headerText}>
|
||||
<Text style={[styles.title, isCancelled && styles.titleCancelled]}>
|
||||
{event.title || 'Untitled'}
|
||||
{event.title || t('calendar.events.no_title', '(No title)')}
|
||||
</Text>
|
||||
{calendar && <Text style={styles.subtitle}>{calendar.name}</Text>}
|
||||
</View>
|
||||
@@ -259,7 +270,7 @@ export function EventDetailSheet({
|
||||
style={styles.joinBtn}
|
||||
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>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -291,10 +302,10 @@ export function EventDetailSheet({
|
||||
|
||||
{canRsvp && (
|
||||
<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}>
|
||||
<RsvpButton
|
||||
label="Yes"
|
||||
label={t('calendar.participants.accepted', 'Accepted')}
|
||||
icon={<Check size={16} color={myStatus === 'accepted' ? c.textInverse : c.success} />}
|
||||
active={myStatus === 'accepted'}
|
||||
activeColor={c.success}
|
||||
@@ -302,7 +313,7 @@ export function EventDetailSheet({
|
||||
onPress={() => doRsvp('accepted')}
|
||||
/>
|
||||
<RsvpButton
|
||||
label="Maybe"
|
||||
label={t('calendar.participants.tentative', 'Tentative')}
|
||||
icon={<HelpCircle size={16} color={myStatus === 'tentative' ? c.textInverse : c.warning} />}
|
||||
active={myStatus === 'tentative'}
|
||||
activeColor={c.warning}
|
||||
@@ -310,7 +321,7 @@ export function EventDetailSheet({
|
||||
onPress={() => doRsvp('tentative')}
|
||||
/>
|
||||
<RsvpButton
|
||||
label="No"
|
||||
label={t('calendar.participants.declined', 'Declined')}
|
||||
icon={<X size={16} color={myStatus === 'declined' ? c.textInverse : c.error} />}
|
||||
active={myStatus === 'declined'}
|
||||
activeColor={c.error}
|
||||
@@ -326,7 +337,7 @@ export function EventDetailSheet({
|
||||
<View style={styles.participantsHeader}>
|
||||
<Users size={16} color={c.textMuted} />
|
||||
<Text style={styles.participantsHeaderText}>
|
||||
{participants.length} participants
|
||||
{participants.length} {t('calendar.participants.title', 'Participants').toLowerCase()}
|
||||
</Text>
|
||||
</View>
|
||||
{participants.map((p) => (
|
||||
@@ -342,11 +353,13 @@ export function EventDetailSheet({
|
||||
<Text style={styles.participantName} numberOfLines={1}>
|
||||
{p.name || p.email || 'Unknown'}
|
||||
{p.isOrganizer ? (
|
||||
<Text style={styles.participantOrganizer}> (organizer)</Text>
|
||||
<Text style={styles.participantOrganizer}>
|
||||
{' '}({t('calendar.participants.organizer', 'Organizer').toLowerCase()})
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
<Text style={styles.participantStatus}>
|
||||
{p.isOrganizer ? '' : statusLabel(p.status)}
|
||||
{p.isOrganizer ? '' : statusLabel(p.status, t)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -359,21 +372,21 @@ export function EventDetailSheet({
|
||||
{onEdit && canEdit && (
|
||||
<ActionButton
|
||||
icon={<Pencil size={18} color={c.text} />}
|
||||
label="Edit"
|
||||
label={t('calendar.participants.edit', 'Edit')}
|
||||
onPress={() => onEdit(event)}
|
||||
/>
|
||||
)}
|
||||
{onDuplicate && (
|
||||
<ActionButton
|
||||
icon={<Copy size={18} color={c.text} />}
|
||||
label="Duplicate"
|
||||
label={t('calendar.events.duplicate', 'Duplicate')}
|
||||
onPress={() => onDuplicate(event)}
|
||||
/>
|
||||
)}
|
||||
{onDelete && canEdit && (
|
||||
<ActionButton
|
||||
icon={<Trash2 size={18} color={c.error} />}
|
||||
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 || '';
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.headerBtn}>
|
||||
<X size={20} color={c.text} />
|
||||
</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
|
||||
variant="default"
|
||||
size="sm"
|
||||
@@ -403,7 +414,7 @@ export function EventModal({
|
||||
disabled={!title.trim() || !calendarId}
|
||||
loading={saving}
|
||||
>
|
||||
Save
|
||||
{t('calendar.form.save', 'Save')}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -415,14 +426,14 @@ export function EventModal({
|
||||
<TextInput
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Title"
|
||||
placeholder={t('calendar.form.title', 'Title')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.titleInput}
|
||||
/>
|
||||
|
||||
<Section
|
||||
icon={<CalendarIcon size={16} color={c.textMuted} />}
|
||||
label="Calendar"
|
||||
label={t('calendar.form.calendar_select', 'Calendar')}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.fieldButton}
|
||||
@@ -438,7 +449,7 @@ export function EventModal({
|
||||
/>
|
||||
)}
|
||||
<Text style={styles.fieldText}>
|
||||
{selectedCalendar?.name || 'Pick a calendar'}
|
||||
{selectedCalendar?.name || t('calendar.form.calendar_select', 'Calendar')}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -469,9 +480,9 @@ export function EventModal({
|
||||
)}
|
||||
</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}>
|
||||
<Text style={styles.fieldText}>All-day</Text>
|
||||
<Text style={styles.fieldText}>{t('calendar.form.all_day_event', 'All-day event')}</Text>
|
||||
<Switch
|
||||
value={allDay}
|
||||
onValueChange={setAllDay}
|
||||
@@ -481,47 +492,47 @@ export function EventModal({
|
||||
</View>
|
||||
|
||||
<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}>
|
||||
<Pressable
|
||||
style={styles.dateBtn}
|
||||
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>
|
||||
{!allDay && (
|
||||
<Pressable
|
||||
style={styles.dateBtn}
|
||||
onPress={() => setShowStartTime(true)}
|
||||
>
|
||||
<Text style={styles.dateText}>{format(start, 'HH:mm')}</Text>
|
||||
<Text style={styles.dateText}>{format(start, timeLabelPattern, { locale })}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<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}>
|
||||
<Pressable
|
||||
style={styles.dateBtn}
|
||||
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>
|
||||
{!allDay && (
|
||||
<Pressable
|
||||
style={styles.dateBtn}
|
||||
onPress={() => setShowEndTime(true)}
|
||||
>
|
||||
<Text style={styles.dateText}>{format(end, 'HH:mm')}</Text>
|
||||
<Text style={styles.dateText}>{format(end, timeLabelPattern, { locale })}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</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
|
||||
style={styles.fieldButton}
|
||||
onPress={() => setRecurrenceOpen((v) => !v)}
|
||||
@@ -548,7 +559,7 @@ export function EventModal({
|
||||
setRecurrenceEditorOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.popoverRowText}>{opt.label}</Text>
|
||||
<Text style={styles.popoverRowText}>{t(opt.key, opt.fallback)}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
@@ -567,12 +578,12 @@ export function EventModal({
|
||||
)}
|
||||
{recurrence === 'custom' && customRule && !recurrenceEditorOpen && (
|
||||
<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>
|
||||
)}
|
||||
</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 && (
|
||||
<View style={{ gap: spacing.xs }}>
|
||||
{reminders.map((r) => (
|
||||
@@ -596,7 +607,7 @@ export function EventModal({
|
||||
>
|
||||
<Plus size={16} color={c.primary} />
|
||||
<Text style={styles.addRowText}>
|
||||
{reminders.length === 0 ? 'Add reminder' : 'Add another reminder'}
|
||||
{t('calendar.alerts.add', 'Add reminder')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
@@ -615,17 +626,17 @@ export function EventModal({
|
||||
)}
|
||||
</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
|
||||
value={location}
|
||||
onChangeText={setLocation}
|
||||
placeholder="Place or address"
|
||||
placeholder={t('calendar.form.location_placeholder', 'Place or address')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.fieldButton}
|
||||
/>
|
||||
</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
|
||||
value={videoUrl}
|
||||
onChangeText={setVideoUrl}
|
||||
@@ -640,12 +651,12 @@ export function EventModal({
|
||||
|
||||
<Section
|
||||
icon={<AlignLeft size={16} color={c.textMuted} />}
|
||||
label="Description"
|
||||
label={t('calendar.form.description', 'Description')}
|
||||
>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Notes, agenda…"
|
||||
placeholder={t('calendar.form.description_placeholder', 'Notes, agenda…')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
multiline
|
||||
style={styles.descriptionInput}
|
||||
@@ -654,7 +665,7 @@ export function EventModal({
|
||||
|
||||
<Section
|
||||
icon={<Users size={16} color={c.textMuted} />}
|
||||
label="Participants"
|
||||
label={t('calendar.participants.title', 'Participants')}
|
||||
>
|
||||
<ParticipantInput
|
||||
attendees={attendees}
|
||||
@@ -674,7 +685,7 @@ export function EventModal({
|
||||
onPress={handleDelete}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
key={key}
|
||||
style={[styles.dayCell, flexLayout && styles.dayCellFlex]}
|
||||
style={[styles.dayCell, flexLayout && styles.dayCellFlex, showTimeInMonthView && styles.dayCellTall]}
|
||||
onPress={() => onSelectDate(d)}
|
||||
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
|
||||
>
|
||||
@@ -106,9 +129,9 @@ function MonthViewInner({
|
||||
{format(d, 'd')}
|
||||
</Text>
|
||||
</View>
|
||||
{dayEvents.length > 0 && (
|
||||
{dayEvents.length > 0 && !showTimeInMonthView && (
|
||||
<View style={styles.dotsRow}>
|
||||
{visibleDots.map((event, idx) => (
|
||||
{visible.map((event, idx) => (
|
||||
<View
|
||||
key={`${event.id}-${idx}`}
|
||||
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
|
||||
@@ -119,6 +142,29 @@ function MonthViewInner({
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -134,7 +180,7 @@ function MonthViewInner({
|
||||
{showWeekNumbers ? (
|
||||
rows.map((row, rowIdx) => (
|
||||
<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))}
|
||||
</View>
|
||||
))
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<RecurrenceEditScope>('this');
|
||||
const isDelete = actionType === 'delete';
|
||||
|
||||
@@ -49,10 +51,15 @@ export function RecurrenceScopeDialog({
|
||||
</View>
|
||||
<View style={styles.headerText}>
|
||||
<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 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>
|
||||
</View>
|
||||
</View>
|
||||
@@ -69,7 +76,7 @@ export function RecurrenceScopeDialog({
|
||||
<View style={[styles.radio, active && styles.radioActive]}>
|
||||
{active && <View style={styles.radioDot} />}
|
||||
</View>
|
||||
<Text style={styles.optionLabel}>{opt.label}</Text>
|
||||
<Text style={styles.optionLabel}>{t(opt.key, opt.fallback)}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
@@ -77,14 +84,16 @@ export function RecurrenceScopeDialog({
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Button variant="outline" size="sm" onPress={onClose}>
|
||||
Cancel
|
||||
{t('calendar.recurrence_scope.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isDelete ? 'destructive' : 'default'}
|
||||
size="sm"
|
||||
onPress={() => onSelect(selected)}
|
||||
>
|
||||
{isDelete ? 'Delete' : 'Save'}
|
||||
{isDelete
|
||||
? t('calendar.recurrence_scope.delete', 'Delete')
|
||||
: t('calendar.recurrence_scope.save', 'Save')}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -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<ScrollView>(null);
|
||||
|
||||
const index = React.useMemo(
|
||||
@@ -136,7 +138,7 @@ function WeekViewInner({
|
||||
style={styles.dayHeaderCell}
|
||||
onPress={() => onSelectDate?.(day)}
|
||||
>
|
||||
<Text style={styles.dayHeaderWeekday}>{format(day, 'EEE')}</Text>
|
||||
<Text style={styles.dayHeaderWeekday}>{format(day, 'EEE', { locale })}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.dayHeaderNumber,
|
||||
@@ -280,7 +282,7 @@ function WeekViewInner({
|
||||
</Text>
|
||||
{height > 32 && (
|
||||
<Text style={styles.eventBlockTime} numberOfLines={1}>
|
||||
{minutesToTimeLabel(startMinutes)}
|
||||
{minutesToTimeLabel(startMinutes, timeFormat)}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<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 (
|
||||
<SettingsSection title="Calendar">
|
||||
<SettingItem label="Default view">
|
||||
<SettingsSection title={t('calendar.settings.title', 'Calendar')}>
|
||||
<SettingItem label={t('calendar.settings.default_view', 'Default view')}>
|
||||
<Select
|
||||
value={viewMode}
|
||||
value={effectiveView}
|
||||
onChange={(v) => update('calendarDefaultView', v as CalendarView)}
|
||||
options={[
|
||||
{ value: 'month', label: 'Month' },
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'day', label: 'Day' },
|
||||
{ value: 'agenda', label: 'Agenda' },
|
||||
{ value: 'month', label: t('calendar.views.month', 'Month') },
|
||||
{ value: 'week', label: t('calendar.views.week', 'Week') },
|
||||
{ value: 'agenda', label: t('calendar.views.agenda', 'Agenda') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Week starts on">
|
||||
<SettingItem label={t('calendar.settings.week_starts_on', 'Week starts on')}>
|
||||
<Select
|
||||
value={String(firstDay)}
|
||||
onChange={(v) => update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)}
|
||||
options={[
|
||||
{ value: '1', label: 'Monday' },
|
||||
{ value: '0', label: 'Sunday' },
|
||||
{ value: '1', label: t('calendar.days.monday', 'Monday') },
|
||||
{ value: '6', label: t('calendar.days.saturday', 'Saturday') },
|
||||
{ value: '0', label: t('calendar.days.sunday', 'Sunday') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Time format">
|
||||
<SettingItem label={t('calendar.settings.time_format', 'Time format')}>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(v) => 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') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Show time in month view"
|
||||
description="Show start times next to events in the month grid."
|
||||
label={t('calendar.settings.time_zone', 'Time zone')}
|
||||
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
|
||||
checked={showTimeInMonth}
|
||||
@@ -75,8 +131,8 @@ export function CalendarSettings() {
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Show week numbers"
|
||||
description="Display ISO week numbers in month and week views."
|
||||
label={t('calendar.settings.show_week_numbers', 'Show week numbers')}
|
||||
description={t('calendar.settings.show_week_numbers_mobile_desc', 'Display week numbers in the month view.')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showWeekNumbers}
|
||||
@@ -85,25 +141,11 @@ export function CalendarSettings() {
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Hover preview"
|
||||
description="Show event details on hover or tap-and-hold."
|
||||
>
|
||||
<Select
|
||||
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."
|
||||
label={t('calendar.settings.show_birthday_calendar', 'Contact birthday calendar')}
|
||||
description={t(
|
||||
'calendar.settings.show_birthday_calendar_desc',
|
||||
'Show a virtual calendar with birthdays from your contacts',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={birthdayCal}
|
||||
@@ -112,8 +154,8 @@ export function CalendarSettings() {
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Enable tasks"
|
||||
description="Show task management alongside events."
|
||||
label={t('calendar.settings.enable_tasks', 'Enable tasks')}
|
||||
description={t('calendar.settings.enable_tasks_desc', 'Show a tasks view in the calendar for managing to-dos')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={tasksEnabled}
|
||||
@@ -123,8 +165,11 @@ export function CalendarSettings() {
|
||||
|
||||
{tasksEnabled && (
|
||||
<SettingItem
|
||||
label="Show tasks on calendar"
|
||||
description="Overlay tasks with due dates on the calendar."
|
||||
label={t('calendar.settings.show_tasks_on_calendar', 'Show tasks on calendar')}
|
||||
description={t(
|
||||
'calendar.settings.show_tasks_on_calendar_desc',
|
||||
'Display task chips on the day and week calendar views',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showTasksOnCal}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -472,9 +472,11 @@ export function getEventColor(
|
||||
* owning JMAP account + the calendar id so it stays unique across accounts.
|
||||
*/
|
||||
export function sharedCalendarColorKey(
|
||||
cal: Pick<Calendar, 'id' | 'accountId'>,
|
||||
cal: Pick<Calendar, 'id' | 'accountId' | 'originalId'>,
|
||||
): 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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() {
|
||||
<Menu size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<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}>
|
||||
{isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')}
|
||||
{isSelectedToday
|
||||
? t('calendar.views.today', 'Today')
|
||||
: format(selectedDate, 'EEE, MMM d', { locale })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
@@ -668,7 +679,7 @@ export default function CalendarScreen() {
|
||||
<ChevronLeft size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Button variant="outline" size="sm" onPress={goToday}>
|
||||
Today
|
||||
{t('calendar.views.today', 'Today')}
|
||||
</Button>
|
||||
<Pressable onPress={goNext} style={styles.navBtn} hitSlop={8}>
|
||||
<ChevronRight size={20} color={c.text} />
|
||||
@@ -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() {
|
||||
<View style={styles.dayDetail}>
|
||||
<View style={styles.dayDetailHeader}>
|
||||
<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>
|
||||
{loading && <ActivityIndicator size="small" color={c.textMuted} />}
|
||||
</View>
|
||||
@@ -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({
|
||||
}
|
||||
>
|
||||
<CalendarDays size={32} color={c.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>No events</Text>
|
||||
<Text style={styles.emptySubtitle}>Tap + to create one</Text>
|
||||
<Text style={styles.emptyTitle}>{t('calendar.events.no_events', 'No events')}</Text>
|
||||
<Text style={styles.emptySubtitle}>{t('calendar.events.tap_to_create', 'Tap + to create one')}</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user