mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e383c3616 | ||
|
|
1543db49d1 | ||
|
|
910e5418e9 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.50",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.50",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.50",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from 'react';
|
||||
import { SectionList, StyleSheet, Text, View } from 'react-native';
|
||||
import { format, isSameDay, isToday, isTomorrow } from 'date-fns';
|
||||
import { addDays, format, isSameDay, isToday, isTomorrow, parseISO, startOfDay } from 'date-fns';
|
||||
import { CalendarDays } from 'lucide-react-native';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
buildEventDayIndex,
|
||||
dayKey,
|
||||
eventsOnDayFromIndex,
|
||||
getEventStartDate,
|
||||
type EventDayIndex,
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
import { EventCard } from './EventCard';
|
||||
|
||||
@@ -19,6 +20,7 @@ interface AgendaViewProps {
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
eventsByDay?: EventDayIndex;
|
||||
timeFormat?: TimeFormat;
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
@@ -40,6 +42,7 @@ export function AgendaView({
|
||||
events,
|
||||
calendars,
|
||||
eventsByDay,
|
||||
timeFormat,
|
||||
onSelectEvent,
|
||||
}: AgendaViewProps) {
|
||||
const c = useColors();
|
||||
@@ -50,32 +53,35 @@ export function AgendaView({
|
||||
);
|
||||
|
||||
const sections = React.useMemo<DaySection[]>(() => {
|
||||
const result: DaySection[] = [];
|
||||
const start = new Date(fromDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
// Mirror webmail's agenda (calendar-agenda-view.tsx): build a group for
|
||||
// every day that actually has events, always include a "Today" anchor, and
|
||||
// render them sorted ascending. Driving the list off the events (rather than
|
||||
// a fixed day-counter) guarantees every upcoming loaded event appears - not
|
||||
// just the ones inside a brittle N-day window from the anchor.
|
||||
const anchor = startOfDay(fromDate);
|
||||
const horizonEnd = addDays(anchor, daysAhead); // safety bound (exclusive)
|
||||
|
||||
for (let i = 0; i < daysAhead; i++) {
|
||||
const day = new Date(start);
|
||||
day.setDate(start.getDate() + i);
|
||||
const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => {
|
||||
// All-day events first within a day, then by start time, then title
|
||||
// (stable tie-break) - mirrors the comparator the webmail uses for
|
||||
// packing week segments and keeps multi-event days predictable.
|
||||
if (a.showWithoutTime !== b.showWithoutTime) {
|
||||
return a.showWithoutTime ? -1 : 1;
|
||||
}
|
||||
const diff = getEventStartDate(a).getTime() - getEventStartDate(b).getTime();
|
||||
if (diff !== 0) return diff;
|
||||
return (a.title || '').localeCompare(b.title || '');
|
||||
});
|
||||
if (dayEvents.length === 0 && !isToday(day)) continue;
|
||||
result.push({
|
||||
const days = new Map<string, Date>();
|
||||
for (const key of index.keys()) {
|
||||
const day = parseISO(key); // index keys are local yyyy-MM-dd
|
||||
if (isNaN(day.getTime())) continue;
|
||||
if (day < anchor || day >= horizonEnd) continue;
|
||||
days.set(key, day);
|
||||
}
|
||||
// Always anchor "Today" so the view has a stable starting point even when
|
||||
// the current day has no events (webmail injects the same anchor).
|
||||
const today = startOfDay(new Date());
|
||||
if (today >= anchor && today < horizonEnd) days.set(dayKey(today), today);
|
||||
|
||||
return [...days.values()]
|
||||
.sort((a, b) => a.getTime() - b.getTime())
|
||||
.map((day) => ({
|
||||
title: formatDayHeader(day),
|
||||
date: day,
|
||||
data: dayEvents,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
// Buckets are pre-sorted by buildEventDayIndex (all-day first, then by
|
||||
// start, then title), so no extra sort is needed here.
|
||||
data: eventsOnDayFromIndex(index, day),
|
||||
}));
|
||||
}, [fromDate, daysAhead, index]);
|
||||
|
||||
if (sections.length === 0) {
|
||||
@@ -107,7 +113,7 @@ export function AgendaView({
|
||||
}}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.itemWrap}>
|
||||
<EventCard event={item} calendars={calendars} onPress={onSelectEvent} />
|
||||
<EventCard event={item} calendars={calendars} timeFormat={timeFormat} onPress={onSelectEvent} />
|
||||
</View>
|
||||
)}
|
||||
renderSectionFooter={({ section }) =>
|
||||
|
||||
@@ -5,11 +5,17 @@ import { format } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { eventTimeRange, getEventColor } from '../../lib/calendar-utils';
|
||||
import {
|
||||
eventTimeRange,
|
||||
getEventColor,
|
||||
timePattern,
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
|
||||
interface EventCardProps {
|
||||
event: CalendarEvent;
|
||||
calendars: Calendar[];
|
||||
timeFormat?: TimeFormat;
|
||||
onPress?: (event: CalendarEvent) => void;
|
||||
onLongPress?: (event: CalendarEvent) => void;
|
||||
}
|
||||
@@ -18,17 +24,18 @@ function participantCount(event: CalendarEvent): number {
|
||||
return event.participants ? Object.keys(event.participants).length : 0;
|
||||
}
|
||||
|
||||
function formatTimeRange(event: CalendarEvent): string {
|
||||
function formatTimeRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
|
||||
const { start, end, allDay } = eventTimeRange(event);
|
||||
if (allDay) return 'All day';
|
||||
return `${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
|
||||
const fmt = timePattern(timeFormat);
|
||||
return `${format(start, fmt)} – ${format(end, fmt)}`;
|
||||
}
|
||||
|
||||
export function EventCard({ event, calendars, onPress, onLongPress }: EventCardProps) {
|
||||
export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }: EventCardProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const color = getEventColor(event, calendars);
|
||||
const time = formatTimeRange(event);
|
||||
const time = formatTimeRange(event, timeFormat);
|
||||
const count = participantCount(event);
|
||||
|
||||
return (
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
eventTimeRange,
|
||||
getEventColor,
|
||||
getPrimaryCalendarId,
|
||||
timePattern,
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts';
|
||||
import {
|
||||
@@ -50,6 +52,7 @@ type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
||||
interface EventDetailSheetProps {
|
||||
event: CalendarEvent | null;
|
||||
calendars: Calendar[];
|
||||
timeFormat?: TimeFormat;
|
||||
onClose: () => void;
|
||||
onEdit?: (event: CalendarEvent) => void;
|
||||
onDelete?: (event: CalendarEvent) => void;
|
||||
@@ -57,7 +60,7 @@ interface EventDetailSheetProps {
|
||||
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function formatRange(event: CalendarEvent): string {
|
||||
function formatRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
|
||||
const { start, end, allDay } = eventTimeRange(event);
|
||||
if (allDay) {
|
||||
if (
|
||||
@@ -69,14 +72,15 @@ function formatRange(event: CalendarEvent): string {
|
||||
}
|
||||
return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`;
|
||||
}
|
||||
const t = timePattern(timeFormat);
|
||||
const sameDay =
|
||||
start.getFullYear() === end.getFullYear() &&
|
||||
start.getMonth() === end.getMonth() &&
|
||||
start.getDate() === end.getDate();
|
||||
if (sameDay) {
|
||||
return `${format(start, 'EEE, MMM d')} · ${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
|
||||
return `${format(start, 'EEE, MMM d')} · ${format(start, t)} – ${format(end, t)}`;
|
||||
}
|
||||
return `${format(start, 'MMM d, HH:mm')} – ${format(end, 'MMM d, HH:mm')}`;
|
||||
return `${format(start, `MMM d, ${t}`)} – ${format(end, `MMM d, ${t}`)}`;
|
||||
}
|
||||
|
||||
function recurrenceLabel(event: CalendarEvent): string | null {
|
||||
@@ -100,6 +104,7 @@ function recurrenceLabel(event: CalendarEvent): string | null {
|
||||
export function EventDetailSheet({
|
||||
event,
|
||||
calendars,
|
||||
timeFormat,
|
||||
onClose,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -156,7 +161,7 @@ export function EventDetailSheet({
|
||||
const color = getEventColor(event, calendars);
|
||||
const calendarId = getPrimaryCalendarId(event);
|
||||
const calendar = calendars.find((c) => c.id === calendarId);
|
||||
const range = formatRange(event);
|
||||
const range = formatRange(event, timeFormat);
|
||||
const recurrence = recurrenceLabel(event);
|
||||
const reminders = alertsToReminders(event.alerts);
|
||||
const participants = event.participants ? Object.values(event.participants) : [];
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
normalizeAllDayDuration,
|
||||
buildAllDayDuration,
|
||||
getCalendarColor,
|
||||
getEventStartDate,
|
||||
getEventEndDate,
|
||||
timePattern,
|
||||
layoutOverlappingEvents,
|
||||
CALENDAR_COLOR_PALETTE,
|
||||
} from '../calendar-utils';
|
||||
@@ -51,6 +54,32 @@ describe('parseLocalDateTime', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEventStartDate / getEventEndDate malformed utc fallback', () => {
|
||||
// Mirrors webmail (#316): a malformed utcStart/utcEnd must fall back to the
|
||||
// floating `start` + duration instead of producing an Invalid Date, which
|
||||
// would silently drop the event from the day index and every view.
|
||||
it('falls back to start when utcStart is unparseable', () => {
|
||||
const d = getEventStartDate(ev({ utcStart: 'garbage' }));
|
||||
expect(isNaN(d.getTime())).toBe(false);
|
||||
expect(d.getHours()).toBe(9);
|
||||
});
|
||||
|
||||
it('falls back to start + duration when utcEnd is unparseable', () => {
|
||||
const end = getEventEndDate(ev({ utcEnd: 'garbage', duration: 'PT1H' }));
|
||||
expect(isNaN(end.getTime())).toBe(false);
|
||||
const start = getEventStartDate(ev({}));
|
||||
expect(end.getTime() - start.getTime()).toBe(60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timePattern', () => {
|
||||
it('maps 12h to an am/pm pattern and 24h (or undefined) to HH:mm', () => {
|
||||
expect(timePattern('12h')).toBe('h:mm a');
|
||||
expect(timePattern('24h')).toBe('HH:mm');
|
||||
expect(timePattern(undefined)).toBe('HH:mm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventTimeRange', () => {
|
||||
it('returns start, end, allDay for a timed event with utcStart/utcEnd', () => {
|
||||
const range = eventTimeRange(ev({
|
||||
|
||||
@@ -35,13 +35,20 @@ export function parseLocalDateTime(iso: string): Date {
|
||||
export function getEventStartDate(
|
||||
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
|
||||
): Date {
|
||||
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start;
|
||||
return parseISO(source);
|
||||
// Prefer utcStart for timed events but fall back to start if utcStart is
|
||||
// missing or unparseable — a malformed utcStart used to surface as an
|
||||
// Invalid Date that silently dropped the event from every view (#316).
|
||||
if (!event.showWithoutTime && event.utcStart) {
|
||||
const utc = parseISO(event.utcStart);
|
||||
if (!isNaN(utc.getTime())) return utc;
|
||||
}
|
||||
return parseISO(event.start);
|
||||
}
|
||||
|
||||
export function getEventEndDate(event: CalendarEvent): Date {
|
||||
if (!event.showWithoutTime && event.utcEnd) {
|
||||
return parseISO(event.utcEnd);
|
||||
const utc = parseISO(event.utcEnd);
|
||||
if (!isNaN(utc.getTime())) return utc;
|
||||
}
|
||||
const start = getEventStartDate(event);
|
||||
if (!event.duration) return start;
|
||||
@@ -57,6 +64,17 @@ export function getEventDisplayEndDate(event: CalendarEvent): Date {
|
||||
return subMilliseconds(end, 1);
|
||||
}
|
||||
|
||||
// ─── Time-of-day formatting ──────────────────────────────
|
||||
// Maps the user's 12h/24h preference to a date-fns pattern, matching webmail
|
||||
// (event-card.tsx: `timeFormat === "12h" ? "h:mm a" : "HH:mm"`). Components
|
||||
// thread the `calendarTimeFormat` setting through so the calendar renders
|
||||
// times in the same format the webmail does.
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
|
||||
export function timePattern(timeFormat: TimeFormat | undefined): string {
|
||||
return timeFormat === '12h' ? 'h:mm a' : 'HH:mm';
|
||||
}
|
||||
|
||||
export interface EventTimeRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
pickUnusedCalendarColor,
|
||||
sharedCalendarColorKey,
|
||||
type EventDayIndex,
|
||||
type TimeFormat,
|
||||
} from '../lib/calendar-utils';
|
||||
import { buildReplyTo } from '../lib/calendar-invitation';
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
|
||||
@@ -291,25 +292,34 @@ export default function CalendarScreen() {
|
||||
[],
|
||||
);
|
||||
|
||||
// A recurring series member is a master (has recurrenceRules) or an expanded
|
||||
// occurrence (has recurrenceId) — those get the this/all scope dialog. Note:
|
||||
// we must NOT key off originalId here, since shared/group events now carry it
|
||||
// for id-namespacing even when they aren't recurring.
|
||||
const isRecurringSeriesMember = React.useCallback(
|
||||
(event: CalendarEvent) => !!event.recurrenceRules?.length || !!event.recurrenceId,
|
||||
[],
|
||||
);
|
||||
|
||||
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
|
||||
setDetailEvent(null);
|
||||
if (event.recurrenceRules?.length || event.originalId) {
|
||||
if (isRecurringSeriesMember(event)) {
|
||||
setPendingAction({ kind: 'edit', event });
|
||||
} else {
|
||||
openEditDirect(event);
|
||||
}
|
||||
}, [openEditDirect, isReadOnlyEvent]);
|
||||
}, [openEditDirect, isReadOnlyEvent, isRecurringSeriesMember]);
|
||||
|
||||
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
|
||||
setDetailEvent(null);
|
||||
if (event.recurrenceRules?.length || event.originalId) {
|
||||
if (isRecurringSeriesMember(event)) {
|
||||
setPendingAction({ kind: 'delete', event });
|
||||
} else {
|
||||
void deleteEvent(event.id);
|
||||
}
|
||||
}, [deleteEvent, isReadOnlyEvent]);
|
||||
}, [deleteEvent, isReadOnlyEvent, isRecurringSeriesMember]);
|
||||
|
||||
const handleScopeSelect = React.useCallback(
|
||||
async (scope: RecurrenceEditScope) => {
|
||||
@@ -459,6 +469,7 @@ export default function CalendarScreen() {
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
timeFormat={calendarTimeFormat}
|
||||
onSelectEvent={setDetailEvent}
|
||||
/>
|
||||
)}
|
||||
@@ -475,6 +486,7 @@ export default function CalendarScreen() {
|
||||
date={selectedDate}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
timeFormat={calendarTimeFormat}
|
||||
onSelectEvent={setDetailEvent}
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
@@ -486,6 +498,7 @@ export default function CalendarScreen() {
|
||||
<EventDetailSheet
|
||||
event={detailEvent}
|
||||
calendars={calendars}
|
||||
timeFormat={calendarTimeFormat}
|
||||
onClose={() => setDetailEvent(null)}
|
||||
onEdit={handleEditFromDetail}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
@@ -570,6 +583,7 @@ function DayEventList({
|
||||
date,
|
||||
eventsByDay,
|
||||
calendars,
|
||||
timeFormat,
|
||||
onSelectEvent,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
@@ -577,6 +591,7 @@ function DayEventList({
|
||||
date: Date;
|
||||
eventsByDay: EventDayIndex;
|
||||
calendars: Calendar[];
|
||||
timeFormat?: TimeFormat;
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
@@ -613,6 +628,7 @@ function DayEventList({
|
||||
key={event.id}
|
||||
event={event}
|
||||
calendars={calendars}
|
||||
timeFormat={timeFormat}
|
||||
onPress={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -101,6 +101,34 @@ describe('calendar-store', () => {
|
||||
expect(useCalendarStore.getState().events).toEqual([]);
|
||||
expect(mockGetEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('namespaces shared/group event ids by owning account and keeps the real id in originalId', async () => {
|
||||
// A shared calendar lives in another account; its event id ("ev1") can
|
||||
// collide with one of the user's own events, so the store must namespace
|
||||
// it. Mirrors webmail's mapServerEventToStoreEvent.
|
||||
useCalendarStore.setState({
|
||||
calendars: [
|
||||
{ id: 'cal-1', name: 'Personal' } as any,
|
||||
{ id: 'shared-cal', name: 'Team', accountId: 'acc-2', isShared: true } as any,
|
||||
],
|
||||
});
|
||||
mockQueryEvents.mockImplementation(async (_ids: string[], _a: string, _b: string, accountId?: string) =>
|
||||
accountId === 'acc-2' ? ['ev1'] : [],
|
||||
);
|
||||
mockGetEvents.mockImplementation(async (_ids: string[], accountId?: string) =>
|
||||
accountId === 'acc-2'
|
||||
? [{ id: 'ev1', title: 'Standup', start: '2026-03-15T10:00:00', calendarIds: { 'shared-cal': true } }]
|
||||
: [],
|
||||
);
|
||||
|
||||
await useCalendarStore.getState().fetchEvents(['cal-1', 'shared-cal'], '2026-03-01', '2026-03-31');
|
||||
|
||||
const stored = useCalendarStore.getState().events;
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored[0].id).toBe('acc-2:ev1');
|
||||
expect(stored[0].originalId).toBe('ev1');
|
||||
expect(stored[0].accountId).toBe('acc-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureRange', () => {
|
||||
|
||||
@@ -152,7 +152,17 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
const eventIds = (await queryEvents(ids, after, before, accountId)) ?? [];
|
||||
if (eventIds.length === 0) continue;
|
||||
const fetched = (await fetchEvents(eventIds, accountId)) ?? [];
|
||||
raw.push(...(accountId ? fetched.map((e) => ({ ...e, accountId })) : fetched));
|
||||
// Shared/group accounts: JMAP object ids are only unique *within* an
|
||||
// account, so two accounts can return events with the same id.
|
||||
// Namespace shared ids with the owning accountId (stashing the real id
|
||||
// in originalId for mutations) so they don't collide with the user's
|
||||
// own events in the store / React keys. Mirrors webmail's
|
||||
// mapServerEventToStoreEvent.
|
||||
raw.push(
|
||||
...(accountId
|
||||
? fetched.map((e) => ({ ...e, accountId, id: `${accountId}:${e.id}`, originalId: e.id }))
|
||||
: fetched),
|
||||
);
|
||||
} catch (err) {
|
||||
// A failing shared account must not hide the user's own events.
|
||||
if (!accountId) throw err;
|
||||
@@ -233,7 +243,12 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
hasSchedulingParticipants(event) ? true : undefined,
|
||||
accountId,
|
||||
);
|
||||
set({ events: [...get().events, accountId ? { ...created, accountId } : created] });
|
||||
// Namespace shared/group event ids the same way fetchEvents does so the
|
||||
// optimistic insert doesn't collide with the user's own events.
|
||||
const storeCreated = accountId
|
||||
? { ...created, accountId, id: `${accountId}:${created.id}`, originalId: created.id }
|
||||
: created;
|
||||
set({ events: [...get().events, storeCreated] });
|
||||
return created;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user