Skip to content
Merged
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
87 changes: 82 additions & 5 deletions src/components/upload-zone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
import { cn } from "@/lib/utils";
import { api } from "@/trpc/react";
import type { ScheduleEvent } from "@/server/services/schedule-analyzer";
import { format, parse } from "date-fns";
import { format, parse, isBefore, startOfDay } from "date-fns";
import { useAuth, useClerk } from "@clerk/nextjs";

// Storage keys for persisting state across OAuth redirect
Expand All @@ -36,6 +36,7 @@ const STORAGE_KEYS = {
fileName: "schedulesync_fileName",
events: "schedulesync_events",
startDate: "schedulesync_startDate",
endDate: "schedulesync_endDate",
} as const;

export function UploadZone() {
Expand All @@ -45,6 +46,7 @@ export function UploadZone() {
const [events, setEvents] = useState<ScheduleEvent[] | null>(null);
const [calendarUrl, setCalendarUrl] = useState<string | null>(null);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);

const { isSignedIn } = useAuth();
const { openSignIn } = useClerk();
Expand Down Expand Up @@ -90,6 +92,15 @@ export function UploadZone() {
}
sessionStorage.removeItem(STORAGE_KEYS.startDate);
}
const savedEndDate = sessionStorage.getItem(STORAGE_KEYS.endDate);
if (savedEndDate) {
try {
setEndDate(new Date(savedEndDate));
} catch {
// Ignore parse errors
}
sessionStorage.removeItem(STORAGE_KEYS.endDate);
}
// Remove the redirect flag after restoring
sessionStorage.removeItem("schedulesync_oauth_redirect");
}
Expand All @@ -98,6 +109,17 @@ export function UploadZone() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]);

// Clear endDate if it becomes invalid (before startDate)
useEffect(() => {
if (
startDate &&
endDate &&
isBefore(startOfDay(endDate), startOfDay(startDate))
) {
setEndDate(undefined);
}
}, [startDate, endDate]);

// Save state to sessionStorage before OAuth redirect
const saveStateForSignIn = useCallback(() => {
// Set OAuth redirect flag
Expand Down Expand Up @@ -145,7 +167,14 @@ export function UploadZone() {
console.error("Failed to save startDate to sessionStorage:", e);
}
}
}, [preview, fileName, events, startDate]);
if (endDate) {
try {
sessionStorage.setItem(STORAGE_KEYS.endDate, endDate.toISOString());
} catch (e) {
console.error("Failed to save endDate to sessionStorage:", e);
}
}
}, [preview, fileName, events, startDate, endDate]);

// Combined handler that saves state first, then opens sign-in modal
const handleSignInClick = useCallback(() => {
Expand Down Expand Up @@ -237,8 +266,8 @@ export function UploadZone() {
const handleDownloadIcal = useCallback(() => {
if (!events || !startDate) return;
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
generateIcal.mutate({ events, timezone, startDate });
}, [events, startDate, generateIcal]);
generateIcal.mutate({ events, timezone, startDate, endDate });
}, [events, startDate, endDate, generateIcal]);

const handleSyncToGoogle = useCallback(() => {
if (!events || !startDate) return;
Expand All @@ -248,8 +277,9 @@ export function UploadZone() {
calendarName: "Class Schedule (Generated by ScheduleSync)",
timezone,
startDate,
endDate,
});
}, [events, startDate, syncToGoogleCalendar]);
}, [events, startDate, endDate, syncToGoogleCalendar]);

return (
<div className="space-y-6">
Expand Down Expand Up @@ -490,6 +520,53 @@ export function UploadZone() {
</Card>
)}

{/* End Date Picker */}
{events && events.length > 0 && (
<Card className="transition-colors">
<CardContent className="p-4">
<div className="space-y-2">
<label className="text-sm font-medium">
When does your quarter (or semester) end? (Optional)
</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!endDate && "text-muted-foreground",
)}
>
<CalendarDays className="mr-2 size-4" />
{endDate ? format(endDate, "PPP") : "Select a date..."}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarComponent
mode="single"
selected={endDate}
onSelect={setEndDate}
initialFocus
disabled={
startDate
? (date) =>
isBefore(startOfDay(date), startOfDay(startDate))
: undefined
}
/>
</PopoverContent>
</Popover>
{!endDate && (
<p className="text-muted-foreground text-xs">
Optional: If provided, recurring events will end on this date
instead of the default 16 weeks from the start date
</p>
)}
</div>
</CardContent>
</Card>
)}

{/* Export Buttons */}
{events && events.length > 0 && (
<div className="space-y-3">
Expand Down
4 changes: 4 additions & 0 deletions src/server/api/routers/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const scheduleRouter = createTRPCRouter({
repeatWeeks: z.number().optional().default(16),
timezone: z.string().optional(),
startDate: z.date(),
endDate: z.date().optional(),
}),
)
.mutation(({ input }) => {
Expand All @@ -48,6 +49,7 @@ export const scheduleRouter = createTRPCRouter({
repeatWeeks: input.repeatWeeks,
timezone: input.timezone,
startDate: input.startDate,
semesterEndDate: input.endDate,
});
return { icalContent };
}),
Expand All @@ -61,6 +63,7 @@ export const scheduleRouter = createTRPCRouter({
repeatWeeks: z.number().optional().default(16),
timezone: z.string().optional(),
startDate: z.date(),
endDate: z.date().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
Expand Down Expand Up @@ -96,6 +99,7 @@ export const scheduleRouter = createTRPCRouter({
repeatWeeks: input.repeatWeeks,
timezone,
startDate: input.startDate,
endDate: input.endDate,
});

// Return the calendar URL
Expand Down
7 changes: 6 additions & 1 deletion src/server/services/google-calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export async function addEventsToCalendar(
repeatWeeks?: number;
timezone?: string;
startDate?: Date;
endDate?: Date;
} = {},
): Promise<void> {
const calendar = google.calendar({ version: "v3" });
Expand All @@ -66,6 +67,7 @@ export async function addEventsToCalendar(
repeatWeeks = 16,
timezone = "UTC",
startDate = new Date(),
endDate,
} = options;

// Process all events in parallel for better performance
Expand All @@ -81,6 +83,7 @@ export async function addEventsToCalendar(
timezone,
startDate,
repeatWeeks,
endDate,
),
),
);
Expand Down Expand Up @@ -132,6 +135,7 @@ async function createRecurringEvent(
timezone: string,
startDate: Date,
repeatWeeks: number,
endDate?: Date,
) {
// Find the next occurrence of this day of week
const dayMap: Record<string, number> = {
Expand All @@ -158,7 +162,8 @@ async function createRecurringEvent(
const endDateTime = parse(event.endTime, "HH:mm", firstOccurrence);

// Calculate the last occurrence (until date)
const lastOccurrence = addWeeks(firstOccurrence, repeatWeeks);
// Use endDate if provided, otherwise calculate from repeatWeeks
const lastOccurrence = endDate ?? addWeeks(firstOccurrence, repeatWeeks);

// Create RRULE for weekly recurrence
// Format lastOccurrence as UTC for RRULE UNTIL (RFC 5545 requires UTC if 'Z' is present)
Expand Down