From a609c65ac200690ca0abf6c9a6f9b0709b797405 Mon Sep 17 00:00:00 2001 From: IsaacPhoon Date: Wed, 11 Mar 2026 00:11:43 -0700 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20download=20iCal?= =?UTF-8?q?=20file=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/availability/availability.tsx | 1 + .../header/availability-header.tsx | 15 +- src/lib/ical.ts | 189 ++++++++++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 src/lib/ical.ts diff --git a/src/components/availability/availability.tsx b/src/components/availability/availability.tsx index f5140e497..d20e7d69d 100644 --- a/src/components/availability/availability.tsx +++ b/src/components/availability/availability.tsx @@ -380,6 +380,7 @@ export function Availability({ meetingData={meetingData} user={user} availabilityDates={availabilityDates} + scheduledBlocks={scheduledBlocks} onCancel={handleCancelEditing} onSave={handleSuccessfulSave} setChangeableTimezone={setChangeableTimezone} diff --git a/src/components/availability/header/availability-header.tsx b/src/components/availability/header/availability-header.tsx index e5fb4e871..c1af7b012 100644 --- a/src/components/availability/header/availability-header.tsx +++ b/src/components/availability/header/availability-header.tsx @@ -5,6 +5,7 @@ import { deleteScheduledTimeBlock, saveScheduledTimeBlock, } from "@actions/meeting/schedule/action"; +import { FileDownload } from "@mui/icons-material"; import { CalendarCheck, CalendarPlus, @@ -19,8 +20,9 @@ import { useShallow } from "zustand/shallow"; import { DeleteModal } from "@/components/availability/header/delete-modal"; import { EditModal } from "@/components/availability/header/edit-modal"; import { Button } from "@/components/ui/button"; -import type { SelectMeeting } from "@/db/schema"; +import type { SelectMeeting, SelectScheduledMeeting } from "@/db/schema"; import type { UserProfile } from "@/lib/auth/user"; +import { downloadICalFile } from "@/lib/ical"; import { cn } from "@/lib/utils"; import type { ZotDate } from "@/lib/zotdate"; import { useAvailabilityViewStore } from "@/store/useAvailabilityViewStore"; @@ -30,6 +32,7 @@ interface AvailabilityHeaderProps { meetingData: SelectMeeting; user: UserProfile | null; availabilityDates: ZotDate[]; + scheduledBlocks: SelectScheduledMeeting[]; onCancel: () => void; onSave: () => void; setChangeableTimezone: (can: boolean) => void; @@ -40,6 +43,7 @@ export function AvailabilityHeader({ meetingData, user, availabilityDates, + scheduledBlocks, onCancel, onSave, setChangeableTimezone, @@ -248,6 +252,15 @@ export function AvailabilityHeader({ {hasAvailability ? "Edit Availability" : "Add Availability"} + )} diff --git a/src/lib/ical.ts b/src/lib/ical.ts new file mode 100644 index 000000000..687752020 --- /dev/null +++ b/src/lib/ical.ts @@ -0,0 +1,189 @@ +import type { SelectMeeting, SelectScheduledMeeting } from "@/db/schema"; + +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(); + 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; +} + +function localDateTimeToDate(date: Date, time: string): Date { + const parts = time.split(":").map(Number); + const h = parts[0]; + const m = parts[1]; + const s = parts[2] ?? 0; + const d = new Date(date); + d.setHours(h, m, s, 0); + return d; +} + +export function generateICalString( + meetingData: SelectMeeting, + scheduledBlocks: SelectScheduledMeeting[] = [], +): string { + 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()); + + // If there are scheduled blocks, export those as the actual calendar events. + // Scheduled blocks are stored in the user's local timezone. + if (scheduledBlocks.length > 0) { + const intervals = mergeScheduledBlocks(scheduledBlocks); + + for (let i = 0; i < intervals.length; i++) { + const interval = intervals[i]; + const startDate = localDateTimeToDate(interval.date, interval.from); + const endDate = localDateTimeToDate(interval.date, interval.to); + + const datePart = interval.date.toISOString().split("T")[0]; + 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"); + } + + // Fallback: no scheduled blocks — export the meeting availability window. + // "days" type meetings use anchor dates (Jan 1-7, 2023) which are not real dates. + if (meetingData.meetingType === "days") { + lines.push("END:VCALENDAR"); + return lines.join("\r\n"); + } + + for (const dateStr of meetingData.dates) { + const datePart = dateStr.substring(0, 10); + const [hours, minutes, seconds = "00"] = meetingData.fromTime.split(":"); + const startDate = new Date(`${datePart}T${hours}:${minutes}:${seconds}Z`); + + const [eh, em, es = "00"] = meetingData.toTime.split(":"); + const endDate = new Date(`${datePart}T${eh}:${em}:${es}Z`); + + // Handle midnight UTC wraparound + if (endDate.getTime() <= startDate.getTime()) { + endDate.setUTCDate(endDate.getUTCDate() + 1); + } + + const uid = `${meetingData.id}-${datePart}@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 downloadICalFile( + meetingData: SelectMeeting, + scheduledBlocks: SelectScheduledMeeting[] = [], +): void { + const icalContent = generateICalString(meetingData, scheduledBlocks); + const blob = new Blob([icalContent], { + type: "text/calendar;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = url; + link.download = `${meetingData.title.replace(/[^a-zA-Z0-9]/g, "_")}.ics`; + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + URL.revokeObjectURL(url); +} From 266e5fd45c879b296a80ec9de19c5ae2e919f7ad Mon Sep 17 00:00:00 2001 From: IsaacPhoon Date: Wed, 11 Mar 2026 00:14:35 -0700 Subject: [PATCH 02/16] =?UTF-8?q?fix:=20=F0=9F=90=9B=20fix=20functionality?= =?UTF-8?q?=20with=20no=20scheduled=20meetings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/ical.ts | 68 +++++++++++-------------------------------------- 1 file changed, 15 insertions(+), 53 deletions(-) diff --git a/src/lib/ical.ts b/src/lib/ical.ts index 687752020..99360e4e5 100644 --- a/src/lib/ical.ts +++ b/src/lib/ical.ts @@ -76,7 +76,11 @@ function localDateTimeToDate(date: Date, time: string): Date { export function generateICalString( meetingData: SelectMeeting, scheduledBlocks: SelectScheduledMeeting[] = [], -): string { +): string | null { + if (scheduledBlocks.length === 0) { + return null; + } + const lines: string[] = [ "BEGIN:VCALENDAR", "VERSION:2.0", @@ -94,60 +98,15 @@ export function generateICalString( : ""; const now = formatICalDateTimeUTC(new Date()); + const intervals = mergeScheduledBlocks(scheduledBlocks); - // If there are scheduled blocks, export those as the actual calendar events. - // Scheduled blocks are stored in the user's local timezone. - if (scheduledBlocks.length > 0) { - const intervals = mergeScheduledBlocks(scheduledBlocks); - - for (let i = 0; i < intervals.length; i++) { - const interval = intervals[i]; - const startDate = localDateTimeToDate(interval.date, interval.from); - const endDate = localDateTimeToDate(interval.date, interval.to); - - const datePart = interval.date.toISOString().split("T")[0]; - 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"); - } + for (let i = 0; i < intervals.length; i++) { + const interval = intervals[i]; + const startDate = localDateTimeToDate(interval.date, interval.from); + const endDate = localDateTimeToDate(interval.date, interval.to); - // Fallback: no scheduled blocks — export the meeting availability window. - // "days" type meetings use anchor dates (Jan 1-7, 2023) which are not real dates. - if (meetingData.meetingType === "days") { - lines.push("END:VCALENDAR"); - return lines.join("\r\n"); - } - - for (const dateStr of meetingData.dates) { - const datePart = dateStr.substring(0, 10); - const [hours, minutes, seconds = "00"] = meetingData.fromTime.split(":"); - const startDate = new Date(`${datePart}T${hours}:${minutes}:${seconds}Z`); - - const [eh, em, es = "00"] = meetingData.toTime.split(":"); - const endDate = new Date(`${datePart}T${eh}:${em}:${es}Z`); - - // Handle midnight UTC wraparound - if (endDate.getTime() <= startDate.getTime()) { - endDate.setUTCDate(endDate.getUTCDate() + 1); - } - - const uid = `${meetingData.id}-${datePart}@zotmeet`; + const datePart = interval.date.toISOString().split("T")[0]; + const uid = `${meetingData.id}-scheduled-${datePart}-${i}@zotmeet`; lines.push("BEGIN:VEVENT"); lines.push(`UID:${uid}`); @@ -173,6 +132,9 @@ export function downloadICalFile( scheduledBlocks: SelectScheduledMeeting[] = [], ): void { const icalContent = generateICalString(meetingData, scheduledBlocks); + + if (!icalContent) return; + const blob = new Blob([icalContent], { type: "text/calendar;charset=utf-8", }); From ef716098fac75431765091a7166e05c2b446e52e Mon Sep 17 00:00:00 2001 From: IsaacPhoon Date: Wed, 11 Mar 2026 10:32:23 -0700 Subject: [PATCH 03/16] =?UTF-8?q?fix:=20=F0=9F=90=9B=20button=20color?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/availability/header/availability-header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/availability/header/availability-header.tsx b/src/components/availability/header/availability-header.tsx index c1af7b012..b9f270a42 100644 --- a/src/components/availability/header/availability-header.tsx +++ b/src/components/availability/header/availability-header.tsx @@ -253,7 +253,7 @@ export function AvailabilityHeader({ + )} diff --git a/src/lib/ical.ts b/src/lib/ical.ts new file mode 100644 index 000000000..687752020 --- /dev/null +++ b/src/lib/ical.ts @@ -0,0 +1,189 @@ +import type { SelectMeeting, SelectScheduledMeeting } from "@/db/schema"; + +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(); + 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; +} + +function localDateTimeToDate(date: Date, time: string): Date { + const parts = time.split(":").map(Number); + const h = parts[0]; + const m = parts[1]; + const s = parts[2] ?? 0; + const d = new Date(date); + d.setHours(h, m, s, 0); + return d; +} + +export function generateICalString( + meetingData: SelectMeeting, + scheduledBlocks: SelectScheduledMeeting[] = [], +): string { + 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()); + + // If there are scheduled blocks, export those as the actual calendar events. + // Scheduled blocks are stored in the user's local timezone. + if (scheduledBlocks.length > 0) { + const intervals = mergeScheduledBlocks(scheduledBlocks); + + for (let i = 0; i < intervals.length; i++) { + const interval = intervals[i]; + const startDate = localDateTimeToDate(interval.date, interval.from); + const endDate = localDateTimeToDate(interval.date, interval.to); + + const datePart = interval.date.toISOString().split("T")[0]; + 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"); + } + + // Fallback: no scheduled blocks — export the meeting availability window. + // "days" type meetings use anchor dates (Jan 1-7, 2023) which are not real dates. + if (meetingData.meetingType === "days") { + lines.push("END:VCALENDAR"); + return lines.join("\r\n"); + } + + for (const dateStr of meetingData.dates) { + const datePart = dateStr.substring(0, 10); + const [hours, minutes, seconds = "00"] = meetingData.fromTime.split(":"); + const startDate = new Date(`${datePart}T${hours}:${minutes}:${seconds}Z`); + + const [eh, em, es = "00"] = meetingData.toTime.split(":"); + const endDate = new Date(`${datePart}T${eh}:${em}:${es}Z`); + + // Handle midnight UTC wraparound + if (endDate.getTime() <= startDate.getTime()) { + endDate.setUTCDate(endDate.getUTCDate() + 1); + } + + const uid = `${meetingData.id}-${datePart}@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 downloadICalFile( + meetingData: SelectMeeting, + scheduledBlocks: SelectScheduledMeeting[] = [], +): void { + const icalContent = generateICalString(meetingData, scheduledBlocks); + const blob = new Blob([icalContent], { + type: "text/calendar;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = url; + link.download = `${meetingData.title.replace(/[^a-zA-Z0-9]/g, "_")}.ics`; + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + URL.revokeObjectURL(url); +} From 09cb31877494e6062c1838ee1d82f3e20ac0b768 Mon Sep 17 00:00:00 2001 From: IsaacPhoon Date: Wed, 11 Mar 2026 00:14:35 -0700 Subject: [PATCH 05/16] =?UTF-8?q?fix:=20=F0=9F=90=9B=20fix=20functionality?= =?UTF-8?q?=20with=20no=20scheduled=20meetings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/ical.ts | 68 +++++++++++-------------------------------------- 1 file changed, 15 insertions(+), 53 deletions(-) diff --git a/src/lib/ical.ts b/src/lib/ical.ts index 687752020..99360e4e5 100644 --- a/src/lib/ical.ts +++ b/src/lib/ical.ts @@ -76,7 +76,11 @@ function localDateTimeToDate(date: Date, time: string): Date { export function generateICalString( meetingData: SelectMeeting, scheduledBlocks: SelectScheduledMeeting[] = [], -): string { +): string | null { + if (scheduledBlocks.length === 0) { + return null; + } + const lines: string[] = [ "BEGIN:VCALENDAR", "VERSION:2.0", @@ -94,60 +98,15 @@ export function generateICalString( : ""; const now = formatICalDateTimeUTC(new Date()); + const intervals = mergeScheduledBlocks(scheduledBlocks); - // If there are scheduled blocks, export those as the actual calendar events. - // Scheduled blocks are stored in the user's local timezone. - if (scheduledBlocks.length > 0) { - const intervals = mergeScheduledBlocks(scheduledBlocks); - - for (let i = 0; i < intervals.length; i++) { - const interval = intervals[i]; - const startDate = localDateTimeToDate(interval.date, interval.from); - const endDate = localDateTimeToDate(interval.date, interval.to); - - const datePart = interval.date.toISOString().split("T")[0]; - 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"); - } + for (let i = 0; i < intervals.length; i++) { + const interval = intervals[i]; + const startDate = localDateTimeToDate(interval.date, interval.from); + const endDate = localDateTimeToDate(interval.date, interval.to); - // Fallback: no scheduled blocks — export the meeting availability window. - // "days" type meetings use anchor dates (Jan 1-7, 2023) which are not real dates. - if (meetingData.meetingType === "days") { - lines.push("END:VCALENDAR"); - return lines.join("\r\n"); - } - - for (const dateStr of meetingData.dates) { - const datePart = dateStr.substring(0, 10); - const [hours, minutes, seconds = "00"] = meetingData.fromTime.split(":"); - const startDate = new Date(`${datePart}T${hours}:${minutes}:${seconds}Z`); - - const [eh, em, es = "00"] = meetingData.toTime.split(":"); - const endDate = new Date(`${datePart}T${eh}:${em}:${es}Z`); - - // Handle midnight UTC wraparound - if (endDate.getTime() <= startDate.getTime()) { - endDate.setUTCDate(endDate.getUTCDate() + 1); - } - - const uid = `${meetingData.id}-${datePart}@zotmeet`; + const datePart = interval.date.toISOString().split("T")[0]; + const uid = `${meetingData.id}-scheduled-${datePart}-${i}@zotmeet`; lines.push("BEGIN:VEVENT"); lines.push(`UID:${uid}`); @@ -173,6 +132,9 @@ export function downloadICalFile( scheduledBlocks: SelectScheduledMeeting[] = [], ): void { const icalContent = generateICalString(meetingData, scheduledBlocks); + + if (!icalContent) return; + const blob = new Blob([icalContent], { type: "text/calendar;charset=utf-8", }); From 546f683f87f9427c5741565f58231348f80a391b Mon Sep 17 00:00:00 2001 From: IsaacPhoon Date: Wed, 11 Mar 2026 11:27:25 -0700 Subject: [PATCH 06/16] =?UTF-8?q?feat:=20=E2=9C=A8=20make=20and=20use=20se?= =?UTF-8?q?rver=20action=20to=20query=20DB=20for=20schedule=20meeting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/availability/availability.tsx | 1 - .../header/availability-header.tsx | 17 +++++++++++----- src/lib/ical.ts | 13 +++--------- .../actions/availability/ical/action.ts | 20 +++++++++++++++++++ 4 files changed, 35 insertions(+), 16 deletions(-) create mode 100644 src/server/actions/availability/ical/action.ts diff --git a/src/components/availability/availability.tsx b/src/components/availability/availability.tsx index 3f2ba6ac3..5891ef75e 100644 --- a/src/components/availability/availability.tsx +++ b/src/components/availability/availability.tsx @@ -608,7 +608,6 @@ export function Availability({ meetingData={meetingData} user={user} availabilityDates={availabilityDates} - scheduledBlocks={scheduledBlocks} onCancel={handleCancelEditing} onSave={handleSuccessfulSave} setChangeableTimezone={setChangeableTimezone} diff --git a/src/components/availability/header/availability-header.tsx b/src/components/availability/header/availability-header.tsx index c041d4898..5c6d46185 100644 --- a/src/components/availability/header/availability-header.tsx +++ b/src/components/availability/header/availability-header.tsx @@ -1,6 +1,7 @@ "use client"; import { getGoogleCalendarPrefilledLink } from "@actions/availability/google/calendar/action"; +import { getICalFileContent } from "@actions/availability/ical/action"; import { saveAvailability } from "@actions/availability/save/action"; import { deleteScheduledTimeBlock, @@ -23,9 +24,9 @@ import { useShallow } from "zustand/shallow"; import { DeleteModal } from "@/components/availability/header/delete-modal"; import { EditModal } from "@/components/availability/header/edit-modal"; import { Button } from "@/components/ui/button"; -import type { SelectMeeting, SelectScheduledMeeting } from "@/db/schema"; +import type { SelectMeeting } from "@/db/schema"; import type { UserProfile } from "@/lib/auth/user"; -import { downloadICalFile } from "@/lib/ical"; +import { triggerICalDownload } from "@/lib/ical"; import { cn } from "@/lib/utils"; import type { ZotDate } from "@/lib/zotdate"; import { useAvailabilityViewStore } from "@/store/useAvailabilityViewStore"; @@ -35,7 +36,6 @@ interface AvailabilityHeaderProps { meetingData: SelectMeeting; user: UserProfile | null; availabilityDates: ZotDate[]; - scheduledBlocks: SelectScheduledMeeting[]; onCancel: () => void; onSave: () => void; setChangeableTimezone: (can: boolean) => void; @@ -46,7 +46,6 @@ export function AvailabilityHeader({ meetingData, user, availabilityDates, - scheduledBlocks, onCancel, onSave, setChangeableTimezone, @@ -311,7 +310,15 @@ export function AvailabilityHeader({ )} + {isScheduled && ( + + )} + {isOwner && ( )} + + {isOwner && ( - )} From daca91fad39ad6a802288cf0b0705895a0749bb0 Mon Sep 17 00:00:00 2001 From: IsaacPhoon Date: Wed, 11 Mar 2026 12:59:44 -0700 Subject: [PATCH 11/16] =?UTF-8?q?feat:=20=E2=9C=A8=20iCal=20no=20longer=20?= =?UTF-8?q?shows=20when=20no=20meeting=20is=20scheduled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../header/availability-header.tsx | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/components/availability/header/availability-header.tsx b/src/components/availability/header/availability-header.tsx index 55ada6609..7ce09ffa7 100644 --- a/src/components/availability/header/availability-header.tsx +++ b/src/components/availability/header/availability-header.tsx @@ -323,24 +323,25 @@ export function AvailabilityHeader({ )} - - + {isScheduled && ( + + )} {isOwner && (