Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ objc2-application-services = "0.3"
objc2-av-foundation = "0.3"
objc2-contacts = "0.3"
objc2-core-foundation = "0.3"
objc2-core-graphics = "0.3"
objc2-event-kit = "0.3"
objc2-foundation = "0.3"
objc2-user-notifications = "0.3"
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/components/devtool/seed/shared/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,23 @@ export const createCalendar = () => {
"Shared Calendar",
]);

const source = faker.helpers.arrayElement([
"iCloud",
"Exchange",
"Google",
"Local",
"Subscribed",
]);

return {
id: id(),
data: {
user_id: DEFAULT_USER_ID,
tracking_id: id(),
name: template,
source,
provider: "apple",
enabled: 1,
created_at: faker.date.past({ years: 1 }).toISOString(),
} satisfies Calendar,
};
Expand Down
36 changes: 27 additions & 9 deletions apps/desktop/src/components/main/sidebar/timeline/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { startOfDay } from "date-fns";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { ChevronDownIcon, ChevronUpIcon, RefreshCwIcon } from "lucide-react";
import { type ReactNode, useMemo } from "react";
import { useScheduleTaskRunCallback } from "tinytick/ui-react";

import { Button } from "@hypr/ui/components/ui/button";
import { cn } from "@hypr/utils";
Expand All @@ -18,6 +19,8 @@ import { useAnchor, useAutoScrollToAnchor } from "./anchor";
import { TimelineItemComponent } from "./item";
import { CurrentTimeIndicator, useCurrentTimeMs } from "./realtime";

const CALENDAR_FETCH_TASK_ID = "fetchAppleCalendarChunk";

export function TimelineView() {
const buckets = useTimelineData();
const hasToday = useMemo(
Expand All @@ -27,6 +30,15 @@ export function TimelineView() {

const currentTab = useTabs((state) => state.currentTab);
const store = main.UI.useStore(main.STORE_ID);
const calendars = main.UI.useTable("calendars", main.STORE_ID);

const hasEnabledCalendar = useMemo(() => {
return Object.values(calendars).some((cal) => cal.enabled === 1);
}, [calendars]);

const triggerCalendarSync = useScheduleTaskRunCallback(
CALENDAR_FETCH_TASK_ID,
);

const selectedSessionId = useMemo(() => {
return currentTab?.type === "sessions" ? currentTab.id : undefined;
Expand Down Expand Up @@ -85,13 +97,10 @@ export function TimelineView() {
}, [buckets, hasToday, todayTimestamp]);

return (
<div className="relative h-full">
<div className="relative h-full flex flex-col">
<div
ref={containerRef}
className={cn([
"flex flex-col h-full overflow-y-auto scrollbar-hide",
"bg-neutral-50 rounded-xl",
])}
className="flex flex-col flex-1 overflow-y-auto scrollbar-hide"
>
{buckets.map((bucket, index) => {
const isToday = bucket.label === "Today";
Expand All @@ -103,12 +112,21 @@ export function TimelineView() {
{shouldRenderIndicatorBefore && (
<CurrentTimeIndicator ref={setCurrentTimeIndicatorRef} />
)}
<div
className={cn(["sticky top-0 z-10", "bg-neutral-50 px-2 py-1"])}
>
<div className="sticky top-0 z-10 bg-background px-2 py-1 flex items-center justify-between">
<div className="text-base font-bold text-neutral-900">
{bucket.label}
</div>
{isToday && hasEnabledCalendar && (
<Button
variant="ghost"
size="icon"
onClick={() => triggerCalendarSync()}
className="size-5 text-neutral-400 hover:text-neutral-600"
aria-label="Refresh calendar events"
>
<RefreshCwIcon className="size-3" />
</Button>
)}
</div>
{isToday ? (
<TodayBucket
Expand Down
Original file line number Diff line number Diff line change
@@ -1,44 +1,26 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircleIcon, ArrowRightIcon, CheckIcon } from "lucide-react";
import { useMemo } from "react";

import {
type AppleCalendar,
commands as appleCalendarCommands,
} from "@hypr/plugin-apple-calendar";
import {
commands as permissionsCommands,
PermissionStatus,
} from "@hypr/plugin-permissions";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@hypr/ui/components/ui/accordion";
import { Button } from "@hypr/ui/components/ui/button";
import { cn } from "@hypr/utils";

import { useIsMacos } from "../../../hooks/usePlatform";
import { PROVIDERS } from "./shared";

export function ConfigureProviders() {
const isMacos = useIsMacos();

const visibleProviders = PROVIDERS.filter(
(p) => p.platform === "all" || (p.platform === "macos" && isMacos),
);

return (
<div className="flex flex-col gap-3">
<h3 className="text-sm font-semibold">Configure Providers</h3>
<Accordion type="single" collapsible className="space-y-3">
{visibleProviders.map((provider) =>
provider.id === "apple" ? (
<AppleCalendarProviderCard key={provider.id} />
) : (
<DisabledProviderCard key={provider.id} config={provider} />
),
)}
</Accordion>
</div>
);
}
import * as main from "../../../../store/tinybase/main";
import { PROVIDERS } from "../shared";
import { CalendarGroup, CalendarItem, CalendarSelection } from "./shared";

function useAccessPermission(config: {
queryKey: string;
Expand Down Expand Up @@ -140,7 +122,116 @@ function AccessPermissionRow({
);
}

function AppleCalendarProviderCard() {
function appleColorToCss(color?: AppleCalendar["color"]): string | undefined {
if (!color) return undefined;
return `rgba(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)}, ${color.alpha})`;
}

function useAppleCalendarSelection() {
const queryClient = useQueryClient();
const store = main.UI.useStore(main.STORE_ID);
const { user_id } = main.UI.useValues(main.STORE_ID);
const storedCalendars = main.UI.useTable("calendars", main.STORE_ID);

const { data: appleCalendars, isLoading } = useQuery({
queryKey: ["appleCalendars"],
queryFn: async () => {
const result = await appleCalendarCommands.listCalendars();
if (result.status === "ok") {
return result.data;
}
throw new Error(result.error);
},
});

const groups = useMemo((): CalendarGroup[] => {
if (!appleCalendars) return [];

const grouped = new Map<string, CalendarItem[]>();
for (const cal of appleCalendars) {
const sourceTitle = cal.source.title;
if (!grouped.has(sourceTitle)) {
grouped.set(sourceTitle, []);
}
grouped.get(sourceTitle)!.push({
id: cal.id,
title: cal.title,
color: appleColorToCss(cal.color),
});
}

return Array.from(grouped.entries()).map(([sourceName, calendars]) => ({
sourceName,
calendars,
}));
}, [appleCalendars]);

const getCalendarRowId = (trackingId: string): string | undefined => {
for (const [rowId, data] of Object.entries(storedCalendars)) {
if (data.tracking_id === trackingId) {
return rowId;
}
}
return undefined;
};

const isCalendarEnabled = (trackingId: string): boolean => {
for (const data of Object.values(storedCalendars)) {
if (data.tracking_id === trackingId) {
return data.enabled === 1;
}
}
return false;
};

const handleToggle = (calendar: CalendarItem, enabled: boolean) => {
if (!store || !user_id) return;

const appleCalendar = appleCalendars?.find((c) => c.id === calendar.id);
if (!appleCalendar) return;

const existingRowId = getCalendarRowId(calendar.id);

if (existingRowId) {
store.setPartialRow("calendars", existingRowId, {
enabled: enabled ? 1 : 0,
});
} else {
const newRowId = crypto.randomUUID();
store.setRow("calendars", newRowId, {
user_id,
created_at: new Date().toISOString(),
tracking_id: calendar.id,
name: calendar.title,
source: appleCalendar.source.title,
provider: "apple",
enabled: enabled ? 1 : 0,
});
}
};

const handleRefresh = () => {
queryClient.invalidateQueries({ queryKey: ["appleCalendars"] });
};

return { groups, isLoading, isCalendarEnabled, handleToggle, handleRefresh };
}

function AppleCalendarSelection() {
const { groups, isCalendarEnabled, handleToggle, handleRefresh } =
useAppleCalendarSelection();

return (
<CalendarSelection
groups={groups}
isCalendarEnabled={isCalendarEnabled}
onToggle={handleToggle}
onRefresh={handleRefresh}
/>
);
}

export function AppleCalendarProviderCard() {
const config = PROVIDERS.find((p) => p.id === "apple")!;

const calendar = useAccessPermission({
Expand Down Expand Up @@ -187,38 +278,8 @@ function AppleCalendarProviderCard() {
onAction={contacts.handleAction}
/>
</div>
{calendar.isAuthorized && <AppleCalendarSelection />}
</AccordionContent>
</AccordionItem>
);
}

function DisabledProviderCard({
config,
}: {
config: (typeof PROVIDERS)[number];
}) {
return (
<AccordionItem
disabled
value={config.id}
className="rounded-xl border-2 border-dashed bg-neutral-50"
>
<AccordionTrigger
className={cn([
"capitalize gap-2 px-4",
"cursor-not-allowed opacity-50",
])}
>
<div className="flex items-center gap-2">
{config.icon}
<span>{config.displayName}</span>
{config.badge && (
<span className="text-xs text-neutral-500 font-light border border-neutral-300 rounded-full px-2">
{config.badge}
</span>
)}
</div>
</AccordionTrigger>
</AccordionItem>
);
}
38 changes: 38 additions & 0 deletions apps/desktop/src/components/settings/calendar/configure/cloud.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
AccordionItem,
AccordionTrigger,
} from "@hypr/ui/components/ui/accordion";
import { cn } from "@hypr/utils";

import { PROVIDERS } from "../shared";

export function DisabledProviderCard({
config,
}: {
config: (typeof PROVIDERS)[number];
}) {
return (
<AccordionItem
disabled
value={config.id}
className="rounded-xl border-2 border-dashed bg-neutral-50"
>
<AccordionTrigger
className={cn([
"capitalize gap-2 px-4",
"cursor-not-allowed opacity-50",
])}
>
<div className="flex items-center gap-2">
{config.icon}
<span>{config.displayName}</span>
{config.badge && (
<span className="text-xs text-neutral-500 font-light border border-neutral-300 rounded-full px-2">
{config.badge}
</span>
)}
</div>
</AccordionTrigger>
</AccordionItem>
);
}
Loading
Loading