Files
calendar/companion/utils/bookings-utils.ts
Dhairyashil ShindeandGitHub 9e84f59216 feat(companion): UI Enhancements for Android and Extension (#26434)
* feat: companion-android-ui-upgrade version 1

* recurrings and unconfirmed booking filter and page implementation

* add badge and links to event type list page

* address cubics comments

* feat(companion): unify dropdown menu for Android and extension (#26486)

* feat(companion): unify dropdown menu for Android and extension

- Merge Android-specific dropdown implementations into base component files
- EventTypeListItem: Add DropdownMenu with Preview, Copy link, Edit, Duplicate, Delete actions
- BookingListItem: Add DropdownMenu with booking actions (reschedule, edit location, add guests, etc.)
- RecurringBookingListItem: Add DropdownMenu with recurring booking actions
- AvailabilityListItem: Add DropdownMenu with Set as Default, Duplicate, Delete actions
- BookingDetailScreen: Add DropdownMenu in header for Android with AlertDialog for cancel confirmation
- Delete all .android.tsx files as implementations are now unified

* fix(companion): fix typecheck errors after dropdown unification

- Remove unused props from EventTypeListItem.ios.tsx (copiedEventTypeId, handleEventTypeLongPress)
- Remove unused onActionsPress prop from BookingListItem.ios.tsx
- Remove copiedEventTypeId and handleEventTypeLongPress props from index.ios.tsx and index.tsx
- Remove onLongPress and onActionsPress props from BookingListScreen.tsx
- Remove handleScheduleLongPress, setSelectedSchedule, setShowActionsModal props from AvailabilityListScreen.tsx
- Fix BookingDetailScreen.tsx to use correct action property names (reschedule.visible instead of canReschedule, etc.)

* fix(companion): remove unused code after dropdown unification

- Remove unused handleEventTypeLongPress function and ActionSheetIOS import from index.ios.tsx
- Remove unused copiedEventTypeId state, handleEventTypeLongPress function, and ActionSheetIOS import from index.tsx
- Prefix unused setSelectedBooking with underscore in BookingListScreen.tsx
- Remove unused handleScheduleLongPress function and ActionSheetIOS import from AvailabilityListScreen.tsx

* feat(companion): unify booking filter UI for Android and extension

- Remove SegmentedControl from web/extension booking list page
- Use Header dropdown for booking status filter on both Android and web
- Use unified event type filter dropdown for both platforms
- Remove unused showFilterModal state and related code
- Pass filterOptions, activeFilter, and onFilterChange to Header for all platforms

* fix(companion): add header padding for web/extension to prevent button clipping

- Add headerLeftContainerStyle and headerRightContainerStyle with 12px padding for web platform
- Applied to root Stack and all nested tab Stack layouts
- Fixes issue where header buttons were touching edges and getting chopped off on extension/web
- Android remains unaffected as the fix is web-only

* fix(companion): add HeaderButtonWrapper for web-only header padding

- Create HeaderButtonWrapper component that adds 12px margin on web only
- Wrap all header buttons with HeaderButtonWrapper to prevent clipping
- Revert invalid screenOptions changes that caused typecheck errors
- Apply fix to all screens with native header buttons:
  - reschedule.tsx, edit-location.tsx, add-guests.tsx
  - mark-no-show.tsx, view-recordings.tsx, meeting-session-details.tsx
  - event-type-detail.tsx, BookingDetailScreen.tsx, profile-sheet.tsx
  - edit-availability-day.tsx, edit-availability-name.tsx, edit-availability-override.tsx

* update more ui-ux

* address cubics comments

* address cubics comments & open event type list page first for inttial render of app
2026-01-07 07:19:07 -03:00

500 lines
15 KiB
TypeScript

import type { BookingFilter } from "@/hooks";
import type { Booking } from "@/services/calcom";
import { safeLogError, safeLogWarn } from "./safeLogger";
export const getEmptyStateContent = (activeFilter: BookingFilter) => {
switch (activeFilter) {
case "upcoming":
return {
icon: "calendar-outline" as const,
title: "No upcoming bookings",
text: "As soon as someone books a time with you it will show up here.",
};
case "unconfirmed":
return {
icon: "calendar-outline" as const,
title: "No unconfirmed bookings",
text: "Your unconfirmed bookings will show up here.",
};
case "past":
return {
icon: "calendar-outline" as const,
title: "No past bookings",
text: "Your past bookings will show up here.",
};
case "cancelled":
return {
icon: "calendar-outline" as const,
title: "No cancelled bookings",
text: "Your canceled bookings will show up here.",
};
case "recurring":
return {
icon: "repeat-outline" as const,
title: "No recurring bookings",
text: "Your recurring bookings will show up here.",
};
default:
return {
icon: "calendar-outline" as const,
title: "No bookings found",
text: "Your bookings will appear here.",
};
}
};
/**
* Format a date string to a time string in 12-hour format
* @param dateString ISO date string
* @returns Formatted time string (e.g., "2:30 PM")
*/
export const formatTime = (dateString: string): string => {
if (!dateString) {
return "";
}
try {
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) {
safeLogWarn("Invalid date string provided to formatTime", { dateString });
return "";
}
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
} catch (error) {
safeLogError("Error formatting time:", error);
return "";
}
};
/**
* Format a date string to a human-readable date
* @param dateString ISO date string
* @param isUpcoming Whether the booking is upcoming (affects format)
* @returns Formatted date string
*/
export const formatDate = (dateString: string, isUpcoming: boolean): string => {
if (!dateString) {
return "";
}
try {
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) {
safeLogWarn("Invalid date string provided to formatDate", { dateString });
return "";
}
const bookingYear = date.getFullYear();
const currentYear = new Date().getFullYear();
const isDifferentYear = bookingYear !== currentYear;
if (isUpcoming) {
// Format: "ddd, D MMM" or "ddd, D MMM YYYY" if different year
const weekday = date.toLocaleDateString("en-US", { weekday: "short" });
const day = date.getDate();
const month = date.toLocaleDateString("en-US", { month: "short" });
if (isDifferentYear) {
return `${weekday}, ${day} ${month} ${bookingYear}`;
}
return `${weekday}, ${day} ${month}`;
} else {
// Format: "D MMMM YYYY" for past bookings
return date.toLocaleDateString("en-US", {
day: "numeric",
month: "long",
year: "numeric",
});
}
} catch (error) {
safeLogError("Error formatting date:", error);
return "";
}
};
/**
* Format a date string to month and year
* @param dateString ISO date string
* @returns Formatted month and year string (e.g., "December 2024" or "This month")
*/
export const formatMonthYear = (dateString: string): string => {
if (!dateString) return "";
try {
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) {
return "";
}
const now = new Date();
const isCurrentMonth =
date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth();
if (isCurrentMonth) {
return "This month";
}
return date.toLocaleDateString("en-US", {
month: "long",
year: "numeric",
});
} catch (_error) {
return "";
}
};
/**
* Get a unique key for a month and year from a date string
* @param dateString ISO date string
* @returns Key in format "YYYY-M" (e.g., "2024-11")
*/
export const getMonthYearKey = (dateString: string): string => {
if (!dateString) return "";
try {
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) {
return "";
}
return `${date.getFullYear()}-${date.getMonth()}`;
} catch (_error) {
return "";
}
};
/**
* Format recurrence pattern into human-readable text
* @param bookings Array of bookings in the recurring series
* @returns Formatted recurrence text (e.g., "Every week for 5 occurrences")
*/
export const formatRecurrencePattern = (bookings: Booking[]): string | null => {
if (bookings.length === 0) return null;
// Try to determine frequency from booking dates
if (bookings.length < 2) {
return `${bookings.length} occurrence`;
}
// Sort bookings by date
const sortedBookings = [...bookings].sort((a, b) => {
const aTime = new Date(a.start || a.startTime || "").getTime();
const bTime = new Date(b.start || b.startTime || "").getTime();
return aTime - bTime;
});
// Calculate interval between first two bookings
const first = new Date(sortedBookings[0].start || sortedBookings[0].startTime || "");
const second = new Date(sortedBookings[1].start || sortedBookings[1].startTime || "");
const diffMs = second.getTime() - first.getTime();
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
let frequency = "";
if (diffDays === 1) {
frequency = "day";
} else if (diffDays === 7) {
frequency = "week";
} else if (diffDays >= 28 && diffDays <= 31) {
frequency = "month";
} else if (diffDays >= 365 && diffDays <= 366) {
frequency = "year";
} else {
// Unknown frequency, just show count
return `${bookings.length} occurrences`;
}
return `Every ${frequency} for ${bookings.length} occurrences`;
};
/**
* Represents a group of recurring bookings with the same recurringBookingUid
*/
export interface RecurringBookingGroup {
recurringBookingUid: string;
bookings: Booking[];
firstUpcoming: Booking;
remainingCount: number;
hasUnconfirmed: boolean;
recurrenceText: string | null;
}
/**
* Type definition for list items (used in FlatList)
*/
export type ListItem =
| { type: "monthHeader"; monthYear: string; key: string }
| { type: "booking"; booking: Booking; key: string }
| { type: "recurringGroup"; group: RecurringBookingGroup; key: string };
/**
* Group bookings by month for display in a sectioned list
* @param bookings Array of bookings to group
* @returns Array of list items with month headers and bookings
*/
export const groupBookingsByMonth = (bookings: Booking[]): ListItem[] => {
const grouped: ListItem[] = [];
let currentMonthYear: string | null = null;
bookings.forEach((booking) => {
const startTime = booking.start || booking.startTime || "";
if (!startTime) return;
const monthYearKey = getMonthYearKey(startTime);
const monthYear = formatMonthYear(startTime);
if (monthYearKey !== currentMonthYear) {
currentMonthYear = monthYearKey;
grouped.push({
type: "monthHeader",
monthYear,
key: `month-${monthYearKey}`,
});
}
grouped.push({
type: "booking",
booking,
key: booking.id.toString(),
});
});
return grouped;
};
/**
* Group recurring bookings by their recurringBookingUid
* @param bookings Array of recurring bookings to group
* @returns Array of RecurringBookingGroup objects
*/
export const groupRecurringBookings = (bookings: Booking[]): RecurringBookingGroup[] => {
const now = new Date();
const groupMap = new Map<string, Booking[]>();
// Group bookings by recurringBookingUid
bookings.forEach((booking) => {
const uid = booking.recurringBookingUid;
if (!uid) return;
if (!groupMap.has(uid)) {
groupMap.set(uid, []);
}
groupMap.get(uid)?.push(booking);
});
// Convert map to RecurringBookingGroup array
const groups: RecurringBookingGroup[] = [];
groupMap.forEach((groupBookings, recurringBookingUid) => {
// Sort bookings by start time (ascending)
const sortedBookings = [...groupBookings].sort((a, b) => {
const aStart = new Date(a.start || a.startTime || "").getTime();
const bStart = new Date(b.start || b.startTime || "").getTime();
return aStart - bStart;
});
// Find first upcoming (non-cancelled) booking
const upcomingBookings = sortedBookings.filter((b) => {
const startTime = new Date(b.start || b.startTime || "");
const isCancelled = b.status?.toLowerCase() === "cancelled";
const isRejected = b.status?.toLowerCase() === "rejected";
return startTime >= now && !isCancelled && !isRejected;
});
const firstUpcoming = upcomingBookings[0] || sortedBookings[0];
// Count remaining (non-cancelled, non-rejected) bookings - reuse upcomingBookings
const remainingCount = upcomingBookings.length;
// Check if any booking requires confirmation
const hasUnconfirmed = sortedBookings.some(
(b) =>
b.status?.toLowerCase() === "pending" ||
b.status?.toLowerCase() === "requires_confirmation" ||
b.requiresConfirmation
);
groups.push({
recurringBookingUid,
bookings: sortedBookings,
firstUpcoming,
remainingCount,
hasUnconfirmed,
recurrenceText: formatRecurrencePattern(sortedBookings),
});
});
// Sort groups by first upcoming booking date
return groups.sort((a, b) => {
const aStart = new Date(a.firstUpcoming.start || a.firstUpcoming.startTime || "").getTime();
const bStart = new Date(b.firstUpcoming.start || b.firstUpcoming.startTime || "").getTime();
return aStart - bStart;
});
};
/**
* Search/filter bookings by query string
* @param bookings Array of bookings to filter
* @param searchQuery Search query string
* @returns Filtered bookings
*/
export const searchBookings = (bookings: Booking[], searchQuery: string): Booking[] => {
if (searchQuery.trim() === "") {
return bookings;
}
const searchLower = searchQuery.toLowerCase();
return bookings.filter(
(booking) =>
// Search in booking title
booking.title?.toLowerCase().includes(searchLower) ||
// Search in booking description
booking.description
?.toLowerCase()
.includes(searchLower) ||
// Search in event type title
booking.eventType?.title
?.toLowerCase()
.includes(searchLower) ||
// Search in attendee names
booking.attendees?.some((attendee) => attendee.name?.toLowerCase().includes(searchLower)) ||
// Search in attendee emails
booking.attendees?.some((attendee) => attendee.email?.toLowerCase().includes(searchLower)) ||
// Search in location
booking.location
?.toLowerCase()
.includes(searchLower) ||
// Search in user name
booking.user?.name
?.toLowerCase()
.includes(searchLower) ||
// Search in user email
booking.user?.email
?.toLowerCase()
.includes(searchLower)
);
};
/**
* Filter bookings by event type ID
* @param bookings Array of bookings to filter
* @param eventTypeId Event type ID to filter by (null means no filter)
* @returns Filtered bookings
*/
export const filterByEventType = (bookings: Booking[], eventTypeId: number | null): Booking[] => {
if (eventTypeId === null) {
return bookings;
}
return bookings.filter((booking) => booking.eventTypeId === eventTypeId);
};
/**
* Format a date string to a complete date/time string
* @param dateString ISO date string
* @returns Formatted date/time string (e.g., "Mon, Dec 25, 2:30 PM")
*/
export const formatDateTime = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
};
/**
* Get color for booking status
* @param status Booking status string
* @returns Hex color code
*/
export const getStatusColor = (status: string): string => {
// API v2 2024-08-13 returns status in lowercase, so normalize to uppercase for comparison
const normalizedStatus = status.toLowerCase();
switch (normalizedStatus) {
case "accepted":
return "#34C759";
case "pending":
return "#FF9500";
case "cancelled":
return "#FF3B30";
case "rejected":
return "#FF3B30";
default:
return "#666";
}
};
/**
* Format status text for display
* @param status Booking status string
* @returns Capitalized status text
*/
export const formatStatusText = (status: string): string => {
// Capitalize first letter for display
return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
};
/**
* Get formatted display string for host and attendees
* @param booking Booking object
* @param currentUserEmail Current user's email (optional)
* @returns Formatted string or null if no host/attendees
*/
export const getHostAndAttendeesDisplay = (
booking: Booking,
currentUserEmail?: string
): string | null => {
const hasHostOrAttendees =
(booking.hosts && booking.hosts.length > 0) ||
booking.user ||
(booking.attendees && booking.attendees.length > 0);
if (!hasHostOrAttendees) return null;
const hostEmail = booking.hosts?.[0]?.email?.toLowerCase() || booking.user?.email?.toLowerCase();
const isCurrentUserHost =
currentUserEmail && hostEmail && currentUserEmail.toLowerCase() === hostEmail;
const hostName = isCurrentUserHost
? "You"
: booking.hosts?.[0]?.name ||
booking.hosts?.[0]?.email ||
booking.user?.name ||
booking.user?.email;
const attendeesDisplay =
booking.attendees && booking.attendees.length > 0
? booking.attendees.length === 1
? booking.attendees[0].name || booking.attendees[0].email
: booking.attendees
.slice(0, 2)
.map((att) => att.name || att.email)
.join(", ") +
(booking.attendees.length > 2 ? ` and ${booking.attendees.length - 2} more` : "")
: null;
if (hostName && attendeesDisplay) {
return `${hostName} and ${attendeesDisplay}`;
} else if (hostName) {
return hostName;
} else if (attendeesDisplay) {
return attendeesDisplay;
}
return null;
};
/**
* Get shareable booking details message
* @param booking Booking object
* @returns Formatted booking details string
*/
export const getBookingDetailsMessage = (booking: Booking): string => {
const attendeesList = booking.attendees?.map((att) => att.name).join(", ") || "No attendees";
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
return `${booking.description ? `${booking.description}\n\n` : ""}Time: ${formatDateTime(
startTime
)} - ${formatTime(endTime)}\nAttendees: ${attendeesList}\nStatus: ${formatStatusText(booking.status)}${
booking.location ? `\nLocation: ${booking.location}` : ""
}`;
};