Skip to content
Open
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
77 changes: 77 additions & 0 deletions src/components/availability/header/availability-header.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"use client";

import { getGoogleCalendarPrefilledLink } from "@actions/availability/google/calendar/action";
import { getICalFileContent } from "@actions/availability/ical/action";
import { getOutlookCalendarLink } from "@actions/availability/outlook/action";
import { saveAvailability } from "@actions/availability/save/action";
import {
deleteScheduledTimeBlock,
saveScheduledTimeBlock,
} from "@actions/meeting/schedule/action";
import { CalendarMonth, FileDownload } from "@mui/icons-material";
import GoogleIcon from "@mui/icons-material/Google";
import {
CalendarCheck,
Expand All @@ -24,6 +27,7 @@ import { EditModal } from "@/components/availability/header/edit-modal";
import { Button } from "@/components/ui/button";
import type { SelectMeeting } from "@/db/schema";
import type { UserProfile } from "@/lib/auth/user";
import { triggerICalDownload } from "@/lib/ical";
import { cn } from "@/lib/utils";
import type { ZotDate } from "@/lib/zotdate";
import { useAvailabilityViewStore } from "@/store/useAvailabilityViewStore";
Expand Down Expand Up @@ -273,6 +277,79 @@ export function AvailabilityHeader({
</Button>
)}

{isScheduled && (
<Button
className={cn(
"h-8 min-h-fit min-w-fit flex-center px-2 md:px-4 md:py-0",
)}
onClick={async () => {
try {
const { success, link, totalDays } =
await getOutlookCalendarLink({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Pass the meeting timezone into the Outlook link generation. The current link uses timezone-less startdt/enddt, so Outlook will interpret the event in the opener's local timezone and shift meetings for users outside meetingData.timezone.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/components/availability/header/availability-header.tsx, line 288:

<comment>Pass the meeting timezone into the Outlook link generation. The current link uses timezone-less `startdt`/`enddt`, so Outlook will interpret the event in the opener's local timezone and shift meetings for users outside `meetingData.timezone`.</comment>

<file context>
@@ -273,6 +277,72 @@ export function AvailabilityHeader({
+										onClick={async () => {
+											try {
+												const { success, link, totalDays } =
+													await getOutlookCalendarLink({
+														meetingId: meetingData.id,
+														meetingTitle: meetingData.title,
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update!

meetingId: meetingData.id,
meetingTitle: meetingData.title,
meetingDescription: meetingData.description,
meetingLocation: meetingData.location,
});

if (!success || !link) {
toast.error(
"Failed to generate Outlook Calendar link.",
);
return;
}

window.open(link, "_blank", "noopener,noreferrer");

toast.success(
totalDays > 1
? `Outlook Calendar link opened for the first day. Use "Download iCal" for all ${totalDays} days.`
: "Outlook Calendar link opened! Confirm the event in your calendar.",
);
} catch (error) {
console.error(
"Error generating Outlook Calendar link:",
error,
);
toast.error(
"An error occurred while generating the Outlook Calendar link.",
);
}
}}
>
<CalendarMonth className="size-5" />
<span className="hidden font-dm-sans md:flex">
Add to Outlook
</span>
</Button>
)}
{isScheduled && (
<Button
className="h-8 min-h-fit min-w-fit flex-center gap-1 px-2 md:px-4 md:py-0"
onClick={async () => {
try {
const { success, content, filename } =
await getICalFileContent(meetingData.id);
if (!success || !content) {
toast.error("No scheduled meeting to download.");
return;
}
triggerICalDownload(content, filename);
} catch (error) {
console.error("Error generating iCal file:", error);
toast.error(
"An error occurred while generating the iCal file.",
);
}
}}
>
<FileDownload className="!text-lg md:!text-base" />
<span className="hidden font-dm-sans md:flex">
Download iCal
</span>
</Button>
)}

{isOwner && (
<Button
className="h-8 min-h-fit min-w-fit flex-center px-2 md:px-4 md:py-0"
Expand Down
177 changes: 177 additions & 0 deletions src/lib/ical.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { fromZonedTime } from "date-fns-tz";
import type { SelectMeeting, SelectScheduledMeeting } from "@/db/schema";
import {
getCurrentWeekDateForAnchor,
isAnchorDateMeeting,
} from "@/lib/types/chrono";

function formatICalDateTimeUTC(date: Date): string {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
const hours = String(date.getUTCHours()).padStart(2, "0");
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
const seconds = String(date.getUTCSeconds()).padStart(2, "0");
return `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
}

function escapeICalText(text: string): string {
return text
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\n/g, "\\n");
}

interface TimeInterval {
date: Date;
from: string; // "HH:mm:ss"
to: string; // "HH:mm:ss"
}

function mergeScheduledBlocks(
blocks: SelectScheduledMeeting[],
): TimeInterval[] {
if (blocks.length === 0) return [];

const byDate = new Map<string, SelectScheduledMeeting[]>();
for (const block of blocks) {
const key = block.scheduledDate.toISOString().split("T")[0];
if (!byDate.has(key)) byDate.set(key, []);
const dateBlocks = byDate.get(key);
if (dateBlocks) dateBlocks.push(block);
}

const intervals: TimeInterval[] = [];

for (const [, dateBlocks] of byDate) {
const sorted = [...dateBlocks].sort((a, b) =>
a.scheduledFromTime.localeCompare(b.scheduledFromTime),
);

let currentFrom = sorted[0].scheduledFromTime;
let currentTo = sorted[0].scheduledToTime;
const date = sorted[0].scheduledDate;

for (let i = 1; i < sorted.length; i++) {
if (sorted[i].scheduledFromTime === currentTo) {
currentTo = sorted[i].scheduledToTime;
} else {
intervals.push({ date, from: currentFrom, to: currentTo });
currentFrom = sorted[i].scheduledFromTime;
currentTo = sorted[i].scheduledToTime;
}
}
intervals.push({ date, from: currentFrom, to: currentTo });
}

return intervals;
}

// Helper function to pad a number to 2 digits
function pad2(n: number): string {
return String(n).padStart(2, "0");
}

function getYmd(date: Date): { yyyy: number; mm: string; dd: string } {
return {
yyyy: date.getFullYear(),
mm: pad2(date.getMonth() + 1),
dd: pad2(date.getDate()),
Comment thread
IsaacPhoon marked this conversation as resolved.
};
}

function zonedLocalDateTimeToUTCDate(
date: Date,
time: string,
timezone: string,
): Date {
const [h, m, s = "00"] = time.split(":");
const { yyyy, mm, dd } = getYmd(date);
return fromZonedTime(`${yyyy}-${mm}-${dd}T${h}:${m}:${s}`, timezone);
}

export function generateICalString(
meetingData: SelectMeeting,
scheduledBlocks: SelectScheduledMeeting[] = [],
): string | null {
if (scheduledBlocks.length === 0) {
return null;
}

const lines: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//ZotMeet//ZotMeet//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
];

const title = escapeICalText(meetingData.title);
const description = meetingData.description
? escapeICalText(meetingData.description)
: "";
const location = meetingData.location
? escapeICalText(meetingData.location)
: "";

const now = formatICalDateTimeUTC(new Date());
const isDaysOfWeek = isAnchorDateMeeting(meetingData.dates);
const intervals = mergeScheduledBlocks(scheduledBlocks);

for (let i = 0; i < intervals.length; i++) {
const interval = intervals[i];

const eventDate = isDaysOfWeek
? getCurrentWeekDateForAnchor(interval.date)
: interval.date;

const startDate = zonedLocalDateTimeToUTCDate(
eventDate,
interval.from,
meetingData.timezone,
);
const endDate = zonedLocalDateTimeToUTCDate(
eventDate,
interval.to,
meetingData.timezone,
);

const { yyyy, mm, dd } = getYmd(eventDate);
const datePart = `${yyyy}-${mm}-${dd}`;
const uid = `${meetingData.id}-scheduled-${datePart}-${i}@zotmeet`;

lines.push("BEGIN:VEVENT");
lines.push(`UID:${uid}`);
lines.push(`DTSTAMP:${now}`);
lines.push(`DTSTART:${formatICalDateTimeUTC(startDate)}`);
lines.push(`DTEND:${formatICalDateTimeUTC(endDate)}`);
lines.push(`SUMMARY:${title}`);
if (description) {
lines.push(`DESCRIPTION:${description}`);
}
if (location) {
lines.push(`LOCATION:${location}`);
}
lines.push("END:VEVENT");
}

lines.push("END:VCALENDAR");
return lines.join("\r\n");
}

export function triggerICalDownload(content: string, filename: string): void {
const blob = new Blob([content], {
type: "text/calendar;charset=utf-8",
});
const url = URL.createObjectURL(blob);

const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();

document.body.removeChild(link);
URL.revokeObjectURL(url);
}
20 changes: 20 additions & 0 deletions src/server/actions/availability/ical/action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"use server";

import {
getExistingMeeting,
getScheduledTimeBlocks,
} from "@data/meeting/queries";
import { generateICalString } from "@/lib/ical";

export async function getICalFileContent(meetingId: string) {
const meetingData = await getExistingMeeting(meetingId);
const blocks = await getScheduledTimeBlocks(meetingId);

const icalContent = generateICalString(meetingData, blocks);

return {
success: icalContent !== null,
content: icalContent,
filename: `${meetingData.title.replace(/[^a-zA-Z0-9]/g, "_")}.ics`,
};
}
Loading
Loading