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.
|
- 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
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 || '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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.
|
* 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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user