Skip to content

Commit 5d3e2d1

Browse files
feat(desktop): add calendar picker UI with permission handling (#2473)
# feat(desktop): add calendar picker UI with permission handling ## Summary This PR adds a calendar picker UI to the desktop settings, allowing users to request calendar/contacts permissions and select which Apple calendars to track. The implementation reorganizes the calendar settings into a modular directory structure. Key changes: - Reorganized `configure.tsx` into `configure/` directory with separate files for Apple, cloud providers, and shared components - Added `CalendarSelection` component that displays calendars grouped by source (iCloud, Exchange, etc.) with toggle switches - Added permission request UI for calendar and contacts access - Includes a note indicating that event fetching is not yet implemented (per requirements) **Important limitation:** Calendar selection state is stored in local React state only and does **not persist** across sessions. The TinyBase `calendars` table schema does not currently have the required fields (`tracking_id`, `enabled`, `source`, `provider`). Persistence can be added once the schema is updated. ## Review & Testing Checklist for Human - [ ] **Test on macOS** (required): This feature uses Apple Calendar APIs and can only be fully tested on macOS. Verify: - Permission request flow works correctly (request → grant → calendar list appears) - Calendar list populates after granting permission - Calendar colors display correctly (RGBA conversion from Apple's color format) - Toggle switches work within the session - [ ] **Verify non-persistence is acceptable**: Calendar toggle state resets when closing/reopening settings. Confirm this is OK until schema changes are made to support persistence. - [ ] **Visual review**: Check that the calendar selection UI renders correctly within the settings panel accordion **Recommended test plan:** 1. Open Settings > Calendar on macOS 2. Expand Apple Calendar provider 3. Request calendar permission if not already granted 4. Verify calendars appear grouped by source (iCloud, Exchange, etc.) 5. Toggle some calendars on/off 6. Close and reopen settings - confirm toggles reset (expected with current implementation) ### Notes - Event fetching is intentionally not implemented yet - the calendar selection will be used once event syncing is available - Calendar selection does not persist - this is a known limitation pending schema updates - Requested by @yujonglee ([email protected]) - Devin session: https://app.devin.ai/sessions/438145d499b246d4b7d2ab257e7f8ecb
1 parent 6acd1f4 commit 5d3e2d1

File tree

7 files changed

+348
-64
lines changed

7 files changed

+348
-64
lines changed

apps/desktop/src/components/devtool/seed/shared/calendar.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const createCalendar = () => {
2424
user_id: DEFAULT_USER_ID,
2525
name: template,
2626
created_at: faker.date.past({ years: 1 }).toISOString(),
27+
enabled: faker.datatype.boolean(),
2728
} satisfies Calendar,
2829
};
2930
};

apps/desktop/src/components/main/body/empty/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ function EmptyView() {
4949
const newNote = useNewNote({ behavior: "current" });
5050
const openCurrent = useTabs((state) => state.openCurrent);
5151
const openCalendar = useCallback(
52-
() => openCurrent({ type: "extension", extensionId: "calendar" }),
52+
() => openCurrent({ type: "calendar" }),
5353
[openCurrent],
5454
);
5555
const openContacts = useCallback(

apps/desktop/src/components/settings/calendar/configure.tsx renamed to apps/desktop/src/components/settings/calendar/configure/apple.tsx

Lines changed: 165 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,30 @@
11
import { useMutation, useQuery } from "@tanstack/react-query";
2-
import { platform } from "@tauri-apps/plugin-os";
32
import { AlertCircleIcon, ArrowRightIcon, CheckIcon } from "lucide-react";
3+
import { useEffect, useMemo } from "react";
44

5+
import {
6+
commands as appleCalendarCommands,
7+
type CalendarColor,
8+
} from "@hypr/plugin-apple-calendar";
59
import {
610
commands as permissionsCommands,
7-
PermissionStatus,
11+
type PermissionStatus,
812
} from "@hypr/plugin-permissions";
913
import {
10-
Accordion,
1114
AccordionContent,
1215
AccordionItem,
1316
AccordionTrigger,
1417
} from "@hypr/ui/components/ui/accordion";
1518
import { Button } from "@hypr/ui/components/ui/button";
1619
import { cn } from "@hypr/utils";
1720

18-
import { PROVIDERS } from "./shared";
19-
20-
export function ConfigureProviders() {
21-
const isMacos = platform() === "macos";
22-
23-
const visibleProviders = PROVIDERS.filter(
24-
(p) => p.platform === "all" || (p.platform === "macos" && isMacos),
25-
);
26-
27-
return (
28-
<div className="flex flex-col gap-3">
29-
<h3 className="text-sm font-semibold">Configure Providers</h3>
30-
<Accordion type="single" collapsible className="space-y-3">
31-
{visibleProviders.map((provider) =>
32-
provider.id === "apple" ? (
33-
<AppleCalendarProviderCard key={provider.id} />
34-
) : (
35-
<DisabledProviderCard key={provider.id} config={provider} />
36-
),
37-
)}
38-
</Accordion>
39-
</div>
40-
);
41-
}
21+
import * as main from "../../../../store/tinybase/main";
22+
import { PROVIDERS } from "../shared";
23+
import {
24+
type CalendarGroup,
25+
type CalendarItem,
26+
CalendarSelection,
27+
} from "./shared";
4228

4329
function useAccessPermission(config: {
4430
queryKey: string;
@@ -81,7 +67,7 @@ function useAccessPermission(config: {
8167
}
8268
};
8369

84-
return { isAuthorized, isPending, handleAction };
70+
return { status: status.data, isAuthorized, isPending, handleAction };
8571
}
8672

8773
function AccessPermissionRow({
@@ -140,7 +126,156 @@ function AccessPermissionRow({
140126
);
141127
}
142128

143-
function AppleCalendarProviderCard() {
129+
function appleColorToCss(color?: CalendarColor | null): string | undefined {
130+
if (!color) return undefined;
131+
return `rgba(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)}, ${color.alpha})`;
132+
}
133+
134+
function useAppleCalendarSelection() {
135+
const { user_id } = main.UI.useValues(main.STORE_ID);
136+
const calendarsTable = main.UI.useTable("calendars", main.STORE_ID);
137+
138+
const {
139+
data: appleCalendars,
140+
refetch,
141+
isFetching,
142+
} = useQuery({
143+
queryKey: ["appleCalendars"],
144+
queryFn: async () => {
145+
const operation = appleCalendarCommands.listCalendars();
146+
const minDelay = new Promise((resolve) => setTimeout(resolve, 500));
147+
148+
const [result] = await Promise.all([operation, minDelay]);
149+
if (result.status === "error") {
150+
throw new Error(result.error);
151+
}
152+
return result.data;
153+
},
154+
});
155+
156+
const createCalendarRow = main.UI.useSetRowCallback(
157+
"calendars",
158+
(p: { id: string; name: string }) => p.id,
159+
(p: { id: string; name: string }) => ({
160+
user_id: user_id ?? "",
161+
created_at: new Date().toISOString(),
162+
name: p.name,
163+
enabled: false,
164+
}),
165+
[user_id],
166+
main.STORE_ID,
167+
);
168+
169+
const updateCalendarName = main.UI.useSetCellCallback(
170+
"calendars",
171+
(p: { id: string; name: string }) => p.id,
172+
"name",
173+
(p: { id: string; name: string }) => p.name,
174+
[],
175+
main.STORE_ID,
176+
);
177+
178+
useEffect(() => {
179+
if (!appleCalendars || !user_id) {
180+
return;
181+
}
182+
183+
for (const cal of appleCalendars) {
184+
const existing = calendarsTable[cal.id];
185+
if (!existing) {
186+
createCalendarRow({ id: cal.id, name: cal.title });
187+
} else if (existing.name !== cal.title) {
188+
updateCalendarName({ id: cal.id, name: cal.title });
189+
}
190+
}
191+
}, [
192+
appleCalendars,
193+
user_id,
194+
calendarsTable,
195+
createCalendarRow,
196+
updateCalendarName,
197+
]);
198+
199+
const groups = useMemo((): CalendarGroup[] => {
200+
if (!appleCalendars) {
201+
return [];
202+
}
203+
204+
const grouped = new Map<string, CalendarItem[]>();
205+
for (const cal of appleCalendars) {
206+
const sourceTitle = cal.source.title;
207+
if (!grouped.has(sourceTitle)) {
208+
grouped.set(sourceTitle, []);
209+
}
210+
grouped.get(sourceTitle)!.push({
211+
id: cal.id,
212+
title: cal.title,
213+
color: appleColorToCss(cal.color),
214+
});
215+
}
216+
217+
return Array.from(grouped.entries()).map(([sourceName, calendars]) => ({
218+
sourceName,
219+
calendars,
220+
}));
221+
}, [appleCalendars]);
222+
223+
const isCalendarEnabled = (calendarId: string): boolean => {
224+
const calendar = calendarsTable[calendarId];
225+
return calendar?.enabled === true;
226+
};
227+
228+
const setCalendarRow = main.UI.useSetRowCallback(
229+
"calendars",
230+
(p: { calendarId: string; enabled: boolean }) => p.calendarId,
231+
(p: { calendarId: string; enabled: boolean }) => {
232+
const existing = calendarsTable[p.calendarId];
233+
if (!existing) {
234+
return {
235+
user_id: user_id ?? "",
236+
created_at: new Date().toISOString(),
237+
name: "",
238+
enabled: p.enabled,
239+
};
240+
}
241+
return {
242+
...existing,
243+
enabled: p.enabled,
244+
};
245+
},
246+
[calendarsTable, user_id],
247+
main.STORE_ID,
248+
);
249+
250+
const handleToggle = (calendar: CalendarItem, enabled: boolean) => {
251+
setCalendarRow({ calendarId: calendar.id, enabled });
252+
};
253+
254+
return {
255+
groups,
256+
isCalendarEnabled,
257+
handleToggle,
258+
handleRefresh: refetch,
259+
isLoading: isFetching,
260+
};
261+
}
262+
263+
function AppleCalendarSelection() {
264+
const { groups, isCalendarEnabled, handleToggle, handleRefresh, isLoading } =
265+
useAppleCalendarSelection();
266+
267+
return (
268+
<CalendarSelection
269+
groups={groups}
270+
isCalendarEnabled={isCalendarEnabled}
271+
onToggle={handleToggle}
272+
onRefresh={handleRefresh}
273+
isLoading={isLoading}
274+
/>
275+
);
276+
}
277+
278+
export function AppleCalendarProviderCard() {
144279
const config = PROVIDERS.find((p) => p.id === "apple")!;
145280

146281
const calendar = useAccessPermission({
@@ -187,38 +322,8 @@ function AppleCalendarProviderCard() {
187322
onAction={contacts.handleAction}
188323
/>
189324
</div>
325+
{calendar.isAuthorized && <AppleCalendarSelection />}
190326
</AccordionContent>
191327
</AccordionItem>
192328
);
193329
}
194-
195-
function DisabledProviderCard({
196-
config,
197-
}: {
198-
config: (typeof PROVIDERS)[number];
199-
}) {
200-
return (
201-
<AccordionItem
202-
disabled
203-
value={config.id}
204-
className="rounded-xl border-2 border-dashed bg-neutral-50"
205-
>
206-
<AccordionTrigger
207-
className={cn([
208-
"capitalize gap-2 px-4",
209-
"cursor-not-allowed opacity-50",
210-
])}
211-
>
212-
<div className="flex items-center gap-2">
213-
{config.icon}
214-
<span>{config.displayName}</span>
215-
{config.badge && (
216-
<span className="text-xs text-neutral-500 font-light border border-neutral-300 rounded-full px-2">
217-
{config.badge}
218-
</span>
219-
)}
220-
</div>
221-
</AccordionTrigger>
222-
</AccordionItem>
223-
);
224-
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {
2+
AccordionItem,
3+
AccordionTrigger,
4+
} from "@hypr/ui/components/ui/accordion";
5+
import { cn } from "@hypr/utils";
6+
7+
import { PROVIDERS } from "../shared";
8+
9+
export function DisabledProviderCard({
10+
config,
11+
}: {
12+
config: (typeof PROVIDERS)[number];
13+
}) {
14+
return (
15+
<AccordionItem
16+
disabled
17+
value={config.id}
18+
className="rounded-xl border-2 border-dashed bg-neutral-50"
19+
>
20+
<AccordionTrigger
21+
className={cn([
22+
"capitalize gap-2 px-4",
23+
"cursor-not-allowed opacity-50",
24+
])}
25+
>
26+
<div className="flex items-center gap-2">
27+
{config.icon}
28+
<span>{config.displayName}</span>
29+
{config.badge && (
30+
<span className="text-xs text-neutral-500 font-light border border-neutral-300 rounded-full px-2">
31+
{config.badge}
32+
</span>
33+
)}
34+
</div>
35+
</AccordionTrigger>
36+
</AccordionItem>
37+
);
38+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { platform } from "@tauri-apps/plugin-os";
2+
3+
import { Accordion } from "@hypr/ui/components/ui/accordion";
4+
5+
import { PROVIDERS } from "../shared";
6+
import { AppleCalendarProviderCard } from "./apple";
7+
import { DisabledProviderCard } from "./cloud";
8+
9+
export function ConfigureProviders() {
10+
const isMacos = platform() === "macos";
11+
12+
const visibleProviders = PROVIDERS.filter(
13+
(p) => p.platform === "all" || (p.platform === "macos" && isMacos),
14+
);
15+
16+
return (
17+
<div className="flex flex-col gap-3">
18+
<h3 className="text-sm font-semibold">Configure Providers</h3>
19+
<Accordion type="single" collapsible className="space-y-3">
20+
{visibleProviders.map((provider) =>
21+
provider.id === "apple" ? (
22+
<AppleCalendarProviderCard key={provider.id} />
23+
) : (
24+
<DisabledProviderCard key={provider.id} config={provider} />
25+
),
26+
)}
27+
</Accordion>
28+
</div>
29+
);
30+
}

0 commit comments

Comments
 (0)