Skip to content
Draft
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
80 changes: 80 additions & 0 deletions app/api/timezone-preference/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";

type TimeZonePreference = "course" | "browser";

/**
* Build the per-course cookie key for storing the user's time zone preference.
*/
function getCookieKey(courseId: number): string {
return `tz_pref_course_${courseId}`;
}

/**
* GET /api/timezone-preference?courseId=123
*
* Returns JSON with the saved preference for the given course if present:
* { preference: "course" | "browser" } or { preference: undefined }
*/
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const courseIdParam = searchParams.get("courseId");
const courseId = Number(courseIdParam);

if (!courseIdParam || Number.isNaN(courseId) || courseId <= 0) {
return NextResponse.json({ error: "Invalid courseId" }, { status: 400 });
}

const cookieStore = await cookies();
const key = getCookieKey(courseId);
const cookie = cookieStore.get(key);
const value = cookie?.value as TimeZonePreference | undefined;

if (value !== "course" && value !== "browser") {
return NextResponse.json({ preference: undefined }, { status: 200 });
}

return NextResponse.json({ preference: value }, { status: 200 });
}

/**
* POST /api/timezone-preference
* Body: { courseId: number, choice: "course" | "browser" }
*
* Sets an httpOnly cookie with security attributes to store the preference.
*/
export async function POST(req: NextRequest) {
let payload: unknown;
try {
payload = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const body = payload as Partial<{ courseId: number; choice: TimeZonePreference }>;
const courseId = Number(body.courseId);
const choice = body.choice;

if (!courseId || Number.isNaN(courseId) || courseId <= 0) {
return NextResponse.json({ error: "Invalid courseId" }, { status: 400 });
}
if (choice !== "course" && choice !== "browser") {
return NextResponse.json({ error: "Invalid choice" }, { status: 400 });
}

const key = getCookieKey(courseId);

// 180 days
const maxAgeSeconds = 60 * 60 * 24 * 180;
const res = NextResponse.json({ ok: true }, { status: 200 });
res.cookies.set({
name: key,
value: choice,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: maxAgeSeconds
});
return res;
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export default function AssignmentPage() {
<Box>
<Heading size="lg">{assignment.title}</Heading>
<HStack>
<AssignmentDueDate assignment={assignment} showLateTokenButton={true} showTimeZone={true} showDue={true} />
<AssignmentDueDate assignment={assignment} showLateTokenButton={true} showDue={true} />
</HStack>
</Box>
</Flex>
Expand Down
25 changes: 13 additions & 12 deletions app/course/[course_id]/assignments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { formatInTimeZone } from "date-fns-tz";
import { useParams } from "next/navigation";
import { useMemo } from "react";
import { FaCheckCircle } from "react-icons/fa";
import { useTimeZonePreference } from "@/hooks/useTimeZonePreference";

// Define the type for the groups query result
type AssignmentGroupMemberWithGroupAndRepo = AssignmentGroupMember & {
Expand Down Expand Up @@ -61,6 +62,8 @@ export default function StudentPage() {
}
});
const course = courseData && courseData.data.length > 0 ? courseData.data[0] : null;
const courseTimeZone = course?.time_zone || "America/New_York";
const { displayTimeZone } = useTimeZonePreference(Number(course_id), courseTimeZone);

const private_profile_id = role.private_profile_id;
const { data: groupsData } = useList<AssignmentGroupMemberWithGroupAndRepo>({
Expand Down Expand Up @@ -114,18 +117,16 @@ export default function StudentPage() {
}

// The view already provides the effective due date with all calculations
const modifiedDueDate = assignment.due_date
? new TZDate(assignment.due_date, course?.time_zone ?? "America/New_York")
: undefined;
const modifiedDueDate = assignment.due_date ? new TZDate(assignment.due_date, displayTimeZone) : undefined;
result.push({
key: assignment.id.toString(),
name: assignment.title!,
type: "assignment",
due_date: modifiedDueDate,
due_date_component: (
<>
{modifiedDueDate &&
formatInTimeZone(modifiedDueDate, course?.time_zone || "America/New_York", "MMM d h:mm aaa")}
{modifiedDueDate && formatInTimeZone(modifiedDueDate, displayTimeZone, "MMM d h:mm aaa")} ({displayTimeZone}
)
</>
),
due_date_link: `/course/${course_id}/assignments/${assignment.id}`,
Expand Down Expand Up @@ -163,23 +164,23 @@ export default function StudentPage() {
const dateB = b.due_date ? new TZDate(b.due_date) : new TZDate(new Date());
return dateB.getTime() - dateA.getTime();
});
}, [assignments, groups, course, course_id]);
}, [assignments, groups, course_id, displayTimeZone]);

const workInFuture = useMemo(() => {
const curTimeInCourseTimezone = new TZDate(new Date(), course?.time_zone ?? "America/New_York");
const curTimeInCourseTimezone = new TZDate(new Date(), displayTimeZone);
return allAssignedWork.filter((work) => {
return work.due_date && work.due_date > curTimeInCourseTimezone;
});
}, [allAssignedWork, course?.time_zone]);
}, [allAssignedWork, displayTimeZone]);
workInFuture.sort((a, b) => {
return (a.due_date?.getTime() ?? 0) - (b.due_date?.getTime() ?? 0);
});
const workInPast = useMemo(() => {
const curTimeInCourseTimezone = new TZDate(new Date(), course?.time_zone ?? "America/New_York");
const curTimeInCourseTimezone = new TZDate(new Date(), displayTimeZone);
return allAssignedWork.filter((work) => {
return work.due_date && work.due_date < curTimeInCourseTimezone;
});
}, [allAssignedWork, course?.time_zone]);
}, [allAssignedWork, displayTimeZone]);
workInPast.sort((a, b) => {
return (b.due_date?.getTime() ?? 0) - (a.due_date?.getTime() ?? 0);
});
Expand All @@ -197,7 +198,7 @@ export default function StudentPage() {
Due Date
<br />
<Text fontSize="sm" color="fg.muted">
({course?.time_zone})
({displayTimeZone})
</Text>
</Table.ColumnHeader>
<Table.ColumnHeader>Name</Table.ColumnHeader>
Expand Down Expand Up @@ -281,7 +282,7 @@ export default function StudentPage() {
Due Date
<br />
<Text fontSize="sm" color="fg.muted">
({course?.time_zone})
({displayTimeZone})
</Text>
</Table.ColumnHeader>
<Table.ColumnHeader>Name</Table.ColumnHeader>
Expand Down
141 changes: 119 additions & 22 deletions app/course/[course_id]/dynamicCourseNav.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"use client";

import { Alert } from "@/components/ui/alert";
import { useColorMode } from "@/components/ui/color-mode";
import {
DrawerBackdrop,
Expand All @@ -16,11 +15,11 @@ import Link from "@/components/ui/link";
import SemesterText from "@/components/ui/semesterText";
import { useClassProfiles } from "@/hooks/useClassProfiles";
import { Course, CourseWithFeatures } from "@/utils/supabase/DatabaseTypes";
import { Box, Button, Flex, HStack, Menu, Portal, Skeleton, Text, VStack } from "@chakra-ui/react";
import { Box, Button, Flex, HStack, Menu, Portal, Skeleton, Text, VStack, Dialog } from "@chakra-ui/react";
import Image from "next/image";
import NextLink from "next/link";
import { usePathname } from "next/navigation";
import React, { Fragment, useEffect, useRef, useState } from "react";
import React, { Fragment, useEffect, useRef } from "react";
import { FaRobot, FaScroll } from "react-icons/fa";
import {
FiAlertCircle,
Expand All @@ -37,6 +36,9 @@ import {
import { MdOutlineMail, MdOutlineScience } from "react-icons/md";
import { TbCards } from "react-icons/tb";
import UserMenu from "../UserMenu";
import { useTimeZonePreference } from "@/hooks/useTimeZonePreference";
import type { TimeZonePreference } from "@/hooks/useTimeZonePreference";
import useModalManager from "@/hooks/useModalManager";

const LinkItems = (courseID: number) => [
{ name: "Assignments", icon: FiCompass, student_only: true, target: `/course/${courseID}/assignments` },
Expand Down Expand Up @@ -159,26 +161,105 @@ function CoursePicker({ currentCourse }: { currentCourse: Course }) {
);
}

function TimeZoneWarning({ courseTz }: { courseTz: string }) {
const [dismissed, setDismissed] = useState(false);
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (courseTz === browserTz || dismissed) {
return <></>;
}
function TimeZonePreferenceModal({
courseId,
courseTz,
initialPreference
}: {
courseId: number;
courseTz: string;
initialPreference?: TimeZonePreference;
}) {
const { isOpen, openModal, closeModal } = useModalManager();
const { shouldPrompt, browserTimeZone, setPreferenceChoice } = useTimeZonePreference(
courseId,
courseTz,
initialPreference
);

useEffect(() => {
if (shouldPrompt) {
openModal();
}
}, [shouldPrompt, openModal]);

return (
<Alert
status="warning"
w={{ base: "100%", md: "fit-content" }}
size="sm"
closable
onClose={() => setDismissed(true)}
>
Warning: This course is in {courseTz} but your computer appears to be in {browserTz}
</Alert>
<Dialog.Root open={isOpen} onOpenChange={(e) => (e.open ? openModal() : closeModal())}>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Select your time zone preference</Dialog.Title>
</Dialog.Header>
<Dialog.Body>
<VStack alignItems="flex-start">
<Text>
Your browser time zone (<b>{browserTimeZone}</b>) differs from this course&apos;s time zone (
<b>{courseTz}</b>).
</Text>
<Text>Choose how you want due dates to be displayed:</Text>
</VStack>
</Dialog.Body>
<Dialog.Footer>
<HStack width="100%" justifyContent="space-between">
<Button
variant="outline"
onClick={() => {
setPreferenceChoice("course");
closeModal();
}}
>
Use course time zone ({courseTz})
</Button>
<Button
colorPalette="green"
onClick={() => {
setPreferenceChoice("browser");
closeModal();
}}
>
Use my browser time zone ({browserTimeZone})
</Button>
</HStack>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
);
}

/**
* Renders a one-line message indicating the effective time zone used for displaying times.
* Internally uses `useTimeZonePreference` to resolve the display zone.
*/
function TimeZoneDisplay({
courseId,
courseTz,
fontSize,
prefix,
initialPreference
}: {
courseId: number;
courseTz: string;
fontSize: "xs" | "sm";
prefix: string;
initialPreference?: TimeZonePreference;
}) {
const { displayTimeZone } = useTimeZonePreference(courseId, courseTz, initialPreference);
return (
<Text fontSize={fontSize} color="fg.muted">
{prefix} {displayTimeZone}
</Text>
);
}

export default function DynamicCourseNav() {
export default function DynamicCourseNav({
initialTimeZonePreference
}: {
initialTimeZonePreference?: TimeZonePreference;
}) {
const pathname = usePathname();
const courseNavRef = useRef<HTMLDivElement>(null);
const { role: enrollment } = useClassProfiles();
Expand Down Expand Up @@ -319,8 +400,13 @@ export default function DynamicCourseNav() {
</HStack>
</Box>

{/* Timezone warning */}
<TimeZoneWarning courseTz={enrollment.classes.time_zone || "America/New_York"} />
<TimeZoneDisplay
courseId={enrollment.class_id}
courseTz={course.time_zone || "America/New_York"}
fontSize="xs"
prefix="Displaying times in"
initialPreference={initialTimeZonePreference}
/>
</VStack>
</Box>

Expand Down Expand Up @@ -403,10 +489,21 @@ export default function DynamicCourseNav() {
})}
</HStack>
</VStack>
<TimeZoneWarning courseTz={enrollment.classes.time_zone || "America/New_York"} />
<TimeZoneDisplay
courseId={enrollment.class_id}
courseTz={course.time_zone || "America/New_York"}
fontSize="sm"
prefix="Times shown in"
initialPreference={initialTimeZonePreference}
/>
<UserMenu />
</Flex>
</Box>
<TimeZonePreferenceModal
courseId={enrollment.class_id}
courseTz={course.time_zone || "America/New_York"}
initialPreference={initialTimeZonePreference}
/>
</Box>
);
}
Loading
Loading