Files
calendar/companion/utils/widgetStorage.shared.ts
T
Peer RichelsenGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>DhairyashilDhairyashil ShindeVolnei Munhoz
8a7cf24f90 feat(companion): add upcoming bookings widget for iOS and Android (#27199)
* feat(companion): add upcoming bookings widget for iOS and Android

- Add iOS widget using @bacons/apple-targets with SwiftUI
- Add Android widget using react-native-android-widget
- Configure App Groups for iOS data sharing between app and widget
- Create widget sync hook to update widget data when bookings change
- Widget displays up to 5 upcoming bookings with title, time, and attendee
- Widget refreshes every 15 minutes and when app goes to background

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix(companion): refactor useWidgetSync to avoid React Compiler limitation

Move conditional logic outside of try/catch block to fix React Compiler
error: 'Support value blocks (conditional, logical, optional chaining,
etc) within a try/catch statement'

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix(companion): fix widget data sync issues

- Fix Swift widget to read data as JSON string (format used by react-native-shared-group-preferences)
- Add fallback to read as raw Data for compatibility
- Fix query key mismatch by checking multiple common filter combinations
- Ensure widget finds cached bookings regardless of which filter the app used

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix(companion): use ExtensionStorage from @bacons/apple-targets for widget data

- Replace react-native-shared-group-preferences with ExtensionStorage
- ExtensionStorage properly stores data in iOS App Groups
- Add ExtensionStorage.reloadWidget() call to trigger widget refresh
- This ensures the widget picks up new data immediately

Co-Authored-By: peer@cal.com <peer@cal.com>

* feat(companion): add upcoming bookings widget for ios - all shapes (#27564)

* ios working widget

* fix(companion): remove sensitive data logging from widget storage

Remove console.log statements that were logging full widgetData payload
and formatted widget bookings containing attendee names and booking
details. This addresses security concerns identified by Cubic AI review
(confidence 9/10).

Co-Authored-By: unknown <>

* dark mode

* new look

* fix deeplink

* fix biome lint

* fix(companion): remove booking titles from console logs to avoid PII exposure

Co-Authored-By: unknown <>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* lock the versions and dev mode logging

* remove dynamic imports

* feat(companion): upcoming bookings widget android (#27689)

* companion-upcoming-bookings-widget-android

* fix(companion): remove stale relative countdowns and fix past-time clamping in Android widget

- Remove relative countdown text (In 15m, In 1h, Now) that becomes stale
  due to Android widget 30+ min update interval (Cubic violation 2)
- Remove Math.max(0, minutes) clamping that caused ended meetings to
  display as 'Now' indefinitely (Cubic violation 1)
- Simplify accent color logic to use startTimeISO directly
- Keep absolute time display (date + startTime) which remains accurate

Co-Authored-By: unknown <>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei@cal.com>
2026-02-06 12:32:46 +00:00

107 lines
3.0 KiB
TypeScript

import { AppState, type AppStateStatus } from "react-native";
export const APP_GROUP_IDENTIFIER = "group.com.cal.companion";
export const WIDGET_BOOKINGS_KEY = "widgetBookings";
export const ANDROID_WIDGET_STORAGE_KEY = "android_widget_bookings";
export interface WidgetBookingData {
id: string;
title: string;
date: string;
startTime: string;
endTime: string;
startTimeISO: string;
attendeeName: string | null;
hostName: string | null;
location: string | null;
hasVideoCall: boolean;
}
export interface WidgetData {
bookings: WidgetBookingData[];
lastUpdated: string | null;
}
export function formatTime(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
}
export function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
}
export interface BookingInput {
id: number;
uid: string;
title: string;
startTime?: string;
endTime?: string;
start?: string;
end?: string;
attendees?: Array<{ name: string; email: string }>;
hosts?: Array<{ name?: string; email?: string }>;
user?: { name?: string; email?: string };
location?: string;
}
export function transformBookingsToWidgetData(bookings: BookingInput[]): WidgetData {
const widgetBookings: WidgetBookingData[] = bookings.slice(0, 5).map((booking) => {
const startTimeStr = booking.startTime || booking.start || "";
const endTimeStr = booking.endTime || booking.end || "";
const hostName = booking.hosts?.[0]?.name || booking.user?.name || null;
const locationLower = (booking.location || "").toLowerCase();
const hasVideoCall =
locationLower.includes("zoom") ||
locationLower.includes("meet") ||
locationLower.includes("teams") ||
locationLower.includes("webex") ||
locationLower.includes("cal video");
return {
id: booking.uid || String(booking.id),
title: booking.title,
date: formatDate(startTimeStr),
startTime: formatTime(startTimeStr),
endTime: formatTime(endTimeStr),
startTimeISO: startTimeStr,
attendeeName: booking.attendees?.[0]?.name || null,
hostName: hostName,
location: booking.location || null,
hasVideoCall: hasVideoCall,
};
});
return {
bookings: widgetBookings,
lastUpdated: new Date().toISOString(),
};
}
export function setupWidgetRefreshOnAppStateChange(
fetchAndUpdateBookings: () => Promise<void>
): () => void {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState === "background" || nextAppState === "inactive") {
fetchAndUpdateBookings().catch((error) => {
console.warn("Failed to refresh widget on app state change:", error);
});
}
};
const subscription = AppState.addEventListener("change", handleAppStateChange);
return () => {
subscription.remove();
};
}