Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions src/app/auth/login/google/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export async function GET(): Promise<Response> {
"profile",
"email",
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar.events.owned",
],
),
);
Expand Down
27 changes: 21 additions & 6 deletions src/app/availability/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import {
OPEN_INVITE_AFTER_CREATE_PARAM,
OPEN_INVITE_AFTER_CREATE_VALUE,
} from "@/lib/meeting-open-invite";
import { loadMergedScheduledInterval } from "@/server/actions/availability/google/calendar/action";
import {
getAllMemberAvailability,
getExistingMeeting,
getMeetingGoogleCalendarSnapshot,
getScheduledTimeBlocks,
} from "@/server/data/meeting/queries";

Expand Down Expand Up @@ -62,13 +64,24 @@ export default async function Page(props: PageProps) {
notFound();
}

const allAvailabilities = await getAllMemberAvailability({
meetingId: meetingData.id,
});

const scheduledBlocks = await getScheduledTimeBlocks(meetingData.id);
const session = await getCurrentSession();
const rawSearch = await props.searchParams;

const [
allAvailabilities,
scheduledBlocks,
mergedScheduledInterval,
googleCalendarLinkSnapshot,
rawSearch,
] = await Promise.all([
getAllMemberAvailability({ meetingId: meetingData.id }),
getScheduledTimeBlocks(meetingData.id),
loadMergedScheduledInterval(meetingData.id),
session.user
? getMeetingGoogleCalendarSnapshot(meetingData.id, session.user.memberId)
: Promise.resolve(null),
props.searchParams,
]);

const openInviteRaw = rawSearch[OPEN_INVITE_AFTER_CREATE_PARAM];
const openInviteFlag = Array.isArray(openInviteRaw)
? openInviteRaw[0]
Expand All @@ -86,6 +99,8 @@ export default async function Page(props: PageProps) {
allAvailabilities={allAvailabilities}
user={session.user}
scheduledBlocks={scheduledBlocks}
mergedScheduledInterval={mergedScheduledInterval}
googleCalendarLinkSnapshot={googleCalendarLinkSnapshot}
autoOpenInviteDialog={autoOpenInviteDialog}
inviteQueryInUrl={inviteQueryInUrl}
/>
Expand Down
105 changes: 105 additions & 0 deletions src/components/availability/add-to-calendar-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"use client";

import { addMeetingToMyGoogleCalendar } from "@actions/availability/google/calendar/action";
import { Cached, Check } from "@mui/icons-material";
import GoogleIcon from "@mui/icons-material/Google";
import { Button } from "@mui/material";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { useSnackbar } from "@/components/ui/snackbar-provider";
import type { MeetingGoogleCalendarSnapshot } from "@/db/schema";
import type { UserProfile } from "@/lib/auth/user";
import { deriveAddToCalendarLabelState } from "@/lib/google-calendar/snapshot";

type AddToCalendarButtonProps = {
meetingId: string;
user: UserProfile | null;
mergedScheduledInterval: MeetingGoogleCalendarSnapshot | null;
googleCalendarLinkSnapshot: MeetingGoogleCalendarSnapshot | null;
className?: string;
};

export function AddToCalendarButton({
meetingId,
user,
mergedScheduledInterval,
googleCalendarLinkSnapshot,
className,
}: AddToCalendarButtonProps) {
const router = useRouter();
const { showSuccess, showError } = useSnackbar();
const [isPending, startTransition] = useTransition();

const state = deriveAddToCalendarLabelState({
storedSnapshot: googleCalendarLinkSnapshot,
currentInterval: mergedScheduledInterval,
});

if (state === "non_contiguous") {
// Schedule is non-contiguous (multi-interval) — API write isn't supported.
return null;
}

let label: string;
let icon: React.ReactNode;
let disabled = isPending;

switch (state) {
case "add":
label = "Add to Calendar";
icon = <GoogleIcon sx={{ fontSize: 18 }} />;
break;
case "drifted":
label = "Update Calendar Event";
icon = <Cached sx={{ fontSize: 18 }} />;
break;
case "in_sync":
label = "Added to Calendar";
icon = <Check sx={{ fontSize: 18 }} />;
disabled = true;
break;
}

const handleClick = () => {
if (!user) {
router.push("/auth/login/google");
return;
}

startTransition(async () => {
try {
const response = await addMeetingToMyGoogleCalendar({ meetingId });
if (!response.success) {
showError(`Could not sync to Google Calendar: ${response.error}.`);
return;
}

const result = response.result;
if (result.status === "synced") {
showSuccess("Synced to your Google Calendar.");
router.refresh();
} else if (result.status === "skipped") {
showError(`Skipped: ${result.reason}.`);
} else {
showError(`Sync failed: ${result.reason}.`);
}
} catch (error) {
console.error("AddToCalendarButton error", error);
showError("An error occurred syncing to Google Calendar.");
}
});
};

return (
<Button
variant="outlined"
size="medium"
startIcon={icon}
disabled={disabled}
onClick={handleClick}
className={className}
>
{isPending ? "Syncing..." : label}
</Button>
);
}
57 changes: 12 additions & 45 deletions src/components/availability/availability-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
"use client";

import { getGoogleCalendarPrefilledLink } from "@actions/availability/google/calendar/action";
import { updateMeetingInvitePermissions } from "@actions/meeting/invite/action";
import {
Create,
GroupAddOutlined,
InsertInvitationRounded,
} from "@mui/icons-material";
import GoogleIcon from "@mui/icons-material/Google";
import { Button, FormControlLabel, Switch } from "@mui/material";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useShallow } from "zustand/shallow";
import { AddToCalendarButton } from "@/components/availability/add-to-calendar-button";
import { useSnackbar } from "@/components/ui/snackbar-provider";
import type { SelectMeeting } from "@/db/schema";
import type { MeetingGoogleCalendarSnapshot, SelectMeeting } from "@/db/schema";
import type { UserProfile } from "@/lib/auth/user";
import { useAvailabilityStore } from "@/store/useAvailabilityStore";

Expand All @@ -29,6 +28,8 @@ export interface AvailabilityActionsProps {
setTimezone: (timezone: string) => void;
onOpenInviteDialog: () => void;
isMeetingDeletionPending?: boolean;
mergedScheduledInterval?: MeetingGoogleCalendarSnapshot | null;
googleCalendarLinkSnapshot?: MeetingGoogleCalendarSnapshot | null;
}

export function AvailabilityActions({
Expand All @@ -43,10 +44,11 @@ export function AvailabilityActions({
setTimezone,
onOpenInviteDialog,
isMeetingDeletionPending = false,
mergedScheduledInterval = null,
googleCalendarLinkSnapshot = null,
}: AvailabilityActionsProps) {
const router = useRouter();
const { showSuccess, showError } = useSnackbar();
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
const [membersCanInvite, setMembersCanInvite] = useState(
meetingData.membersCanInvite,
);
Expand Down Expand Up @@ -150,47 +152,12 @@ export function AvailabilityActions({
)}
{isScheduled && (
<div className="hidden flex-col sm:flex">
<Button
variant="outlined"
size="medium"
startIcon={<GoogleIcon sx={{ fontSize: 18 }} />}
onClick={async () => {
if (isGeneratingLink) return;
setIsGeneratingLink(true);
try {
const { success, link } =
await getGoogleCalendarPrefilledLink({
meetingId: meetingData.id,
meetingTitle: meetingData.title,
meetingDescription: meetingData.description,
meetingLocation: meetingData.location,
timezone: meetingData.timezone,
});

if (!success || !link) {
showError("Failed to generate Google Calendar link.");
return;
}

window.open(link, "_blank", "noopener,noreferrer");
showSuccess(
"Google Calendar link opened! Confirm the event in your calendar.",
);
} catch (error) {
console.error(
"Error generating Google Calendar link:",
error,
);
showError(
"An error occurred while generating the Google Calendar link.",
);
} finally {
setIsGeneratingLink(false);
}
}}
>
Add to Calendar
</Button>
<AddToCalendarButton
meetingId={meetingData.id}
user={user}
mergedScheduledInterval={mergedScheduledInterval}
googleCalendarLinkSnapshot={googleCalendarLinkSnapshot}
/>
</div>
)}
<div className="hidden sm:block">
Expand Down
12 changes: 11 additions & 1 deletion src/components/availability/availability.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import { ScheduleMeetingSettings } from "@/components/availability/schedule-meet
import { AvailabilityTableHeader } from "@/components/availability/table/availability-table-header";
import { TimeZoneDropdown } from "@/components/availability/table/availability-timezone";
import { InviteMembersDialog } from "@/components/groups/add-member-dialog";
import type { SelectMeeting, SelectScheduledMeeting } from "@/db/schema";
import type {
MeetingGoogleCalendarSnapshot,
SelectMeeting,
SelectScheduledMeeting,
} from "@/db/schema";
import { useAvailabilityActionHandlers } from "@/hooks/use-availability-action-handlers";
import { useAvailabilityData } from "@/hooks/use-availability-data";
import { useCalendarOverlays } from "@/hooks/use-calendar-overlays";
Expand Down Expand Up @@ -46,13 +50,17 @@ export function Availability({
allAvailabilities,
user,
scheduledBlocks,
mergedScheduledInterval = null,
googleCalendarLinkSnapshot = null,
autoOpenInviteDialog = false,
inviteQueryInUrl = false,
}: {
meetingData: SelectMeeting;
allAvailabilities: MemberMeetingAvailability[];
user: UserProfile | null;
scheduledBlocks: SelectScheduledMeeting[];
mergedScheduledInterval?: MeetingGoogleCalendarSnapshot | null;
googleCalendarLinkSnapshot?: MeetingGoogleCalendarSnapshot | null;
autoOpenInviteDialog?: boolean;
inviteQueryInUrl?: boolean;
}) {
Expand Down Expand Up @@ -340,6 +348,8 @@ export function Availability({
setTimezone: setUserTimezone,
onOpenInviteDialog: handleOpenInviteDialog,
isMeetingDeletionPending,
mergedScheduledInterval,
googleCalendarLinkSnapshot,
};

const isMeetingOwner = Boolean(user && meetingData.hostId === user.memberId);
Expand Down
46 changes: 30 additions & 16 deletions src/components/meetings/delete-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"use client";

import { archiveMeeting } from "@actions/meeting/archive/action";
import {
type ArchiveMeetingResult,
archiveMeeting,
} from "@actions/meeting/archive/action";
import { leaveMeeting } from "@actions/meeting/leave/action";
import {
Button,
Expand Down Expand Up @@ -38,7 +41,7 @@ export const DeleteModal = ({
}: DeleteModalProps) => {
const router = useRouter();
const pathname = usePathname();
const { showSuccess, showError } = useSnackbar();
const { showSuccess, showError, showWarning } = useSnackbar();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"), {
noSsr: true,
Expand All @@ -57,24 +60,35 @@ export const DeleteModal = ({
const handleConfirm = async () => {
onDeletionPendingChange(true);
try {
const { success, error } = isOwner
? await archiveMeeting(meetingData)
: await leaveMeeting(meetingData);
if (isOwner) {
const result: ArchiveMeetingResult = await archiveMeeting(meetingData);
if (!result.success) {
showError(result.error ?? "Something went wrong.");
return;
}

if (success) {
showSuccess(
isOwner
? `You successfully deleted "${meetingData.title}".`
: `You left "${meetingData.title}".`,
);
handleOpenChange(false);
if (pathname === "/summary") {
router.refresh();
const partialFailures = result.calendarOutcome?.failed ?? 0;
if (partialFailures > 0) {
showWarning(
`Deleted "${meetingData.title}", but could not remove the event from ${partialFailures} member calendar(s). Members may need to delete it manually.`,
);
} else {
router.push("/summary");
showSuccess(`You successfully deleted "${meetingData.title}".`);
}
} else {
const result = await leaveMeeting(meetingData);
if (!result.success) {
showError(result.error ?? "Something went wrong.");
return;
}
showSuccess(`You left "${meetingData.title}".`);
}

handleOpenChange(false);
if (pathname === "/summary") {
router.refresh();
} else {
showError(error ?? "Something went wrong.");
router.push("/summary");
}
} finally {
onDeletionPendingChange(false);
Expand Down
Loading
Loading