diff --git a/app/api/timezone-preference/route.ts b/app/api/timezone-preference/route.ts new file mode 100644 index 00000000..2a4e0315 --- /dev/null +++ b/app/api/timezone-preference/route.ts @@ -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; +} diff --git a/app/course/[course_id]/assignments/[assignment_id]/page.tsx b/app/course/[course_id]/assignments/[assignment_id]/page.tsx index e027ca5d..648bdf04 100644 --- a/app/course/[course_id]/assignments/[assignment_id]/page.tsx +++ b/app/course/[course_id]/assignments/[assignment_id]/page.tsx @@ -139,7 +139,7 @@ export default function AssignmentPage() { {assignment.title} - + diff --git a/app/course/[course_id]/assignments/page.tsx b/app/course/[course_id]/assignments/page.tsx index 27f57fbe..bc8e088a 100644 --- a/app/course/[course_id]/assignments/page.tsx +++ b/app/course/[course_id]/assignments/page.tsx @@ -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 & { @@ -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({ @@ -114,9 +117,7 @@ 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!, @@ -124,8 +125,8 @@ export default function StudentPage() { 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}`, @@ -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); }); @@ -197,7 +198,7 @@ export default function StudentPage() { Due Date
- ({course?.time_zone}) + ({displayTimeZone}) Name @@ -281,7 +282,7 @@ export default function StudentPage() { Due Date
- ({course?.time_zone}) + ({displayTimeZone}) Name diff --git a/app/course/[course_id]/dynamicCourseNav.tsx b/app/course/[course_id]/dynamicCourseNav.tsx index 58746a94..96807912 100644 --- a/app/course/[course_id]/dynamicCourseNav.tsx +++ b/app/course/[course_id]/dynamicCourseNav.tsx @@ -1,6 +1,5 @@ "use client"; -import { Alert } from "@/components/ui/alert"; import { useColorMode } from "@/components/ui/color-mode"; import { DrawerBackdrop, @@ -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, @@ -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` }, @@ -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 ( - setDismissed(true)} - > - Warning: This course is in {courseTz} but your computer appears to be in {browserTz} - + (e.open ? openModal() : closeModal())}> + + + + + + Select your time zone preference + + + + + Your browser time zone ({browserTimeZone}) differs from this course's time zone ( + {courseTz}). + + Choose how you want due dates to be displayed: + + + + + + + + + + + + + ); +} + +/** + * 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 ( + + {prefix} {displayTimeZone} + ); } -export default function DynamicCourseNav() { +export default function DynamicCourseNav({ + initialTimeZonePreference +}: { + initialTimeZonePreference?: TimeZonePreference; +}) { const pathname = usePathname(); const courseNavRef = useRef(null); const { role: enrollment } = useClassProfiles(); @@ -319,8 +400,13 @@ export default function DynamicCourseNav() { - {/* Timezone warning */} - + @@ -403,10 +489,21 @@ export default function DynamicCourseNav() { })} - + + ); } diff --git a/app/course/[course_id]/layout.tsx b/app/course/[course_id]/layout.tsx index 13df5d72..1cd8aa07 100644 --- a/app/course/[course_id]/layout.tsx +++ b/app/course/[course_id]/layout.tsx @@ -9,6 +9,13 @@ import { OfficeHoursControllerProvider } from "@/hooks/useOfficeHoursRealtime"; import { redirect } from "next/navigation"; import DynamicCourseNav from "./dynamicCourseNav"; import { getCourse, getUserRolesForCourse } from "@/lib/ssrUtils"; +import { cookies } from "next/headers"; + +type TimeZonePreference = "course" | "browser"; + +function getCookieKey(courseId: number): string { + return `tz_pref_course_${courseId}`; +} export async function generateMetadata({ params }: { params: Promise<{ course_id: string }> }) { const { course_id } = await params; @@ -30,6 +37,10 @@ const ProtectedLayout = async ({ if (!user_role) { redirect("/"); } + // Seed initial time zone preference from server cookie (if set) + const cookieStore = await cookies(); + const key = getCookieKey(Number.parseInt(course_id)); + const pref = cookieStore.get(key)?.value as TimeZonePreference | undefined; // const {open, onOpen, onClose} = useDisclosure() return ( @@ -43,7 +54,7 @@ const ProtectedLayout = async ({ profileId={user_role.private_profile_id} role={user_role.role} > - + {/* */} {/* mobilenav */} diff --git a/components/ui/assignment-due-date.tsx b/components/ui/assignment-due-date.tsx index 72ba98c2..6630fe1b 100644 --- a/components/ui/assignment-due-date.tsx +++ b/components/ui/assignment-due-date.tsx @@ -14,6 +14,7 @@ import { Button } from "./button"; import { Skeleton } from "./skeleton"; import { toaster, Toaster } from "./toaster"; import { AssignmentsForStudentDashboard } from "@/app/course/[course_id]/assignments/page"; +import { useTimeZonePreference } from "@/hooks/useTimeZonePreference"; function LateTokenButton({ assignment }: { assignment: Assignment }) { const { private_profile_id, role } = useClassProfiles(); @@ -174,8 +175,7 @@ function LateTokenButton({ assignment }: { assignment: Assignment }) { description: "The late token has been consumed and the due date has been extended by 24 hours.", type: "success" }); - } catch (err) { - console.error(err); + } catch { toaster.create({ title: "Error consuming late token", description: @@ -196,18 +196,19 @@ function LateTokenButton({ assignment }: { assignment: Assignment }) { export function AssignmentDueDate({ assignment, showLateTokenButton = false, - showTimeZone = false, showDue = false }: { assignment: Assignment; showLateTokenButton?: boolean; - showTimeZone?: boolean; showDue?: boolean; }) { const { private_profile_id } = useClassProfiles(); - const { dueDate, originalDueDate, hoursExtended, lateTokensConsumed, time_zone } = useAssignmentDueDate(assignment, { + const { dueDate, originalDueDate, hoursExtended, lateTokensConsumed } = useAssignmentDueDate(assignment, { studentPrivateProfileId: private_profile_id }); + const { role } = useClassProfiles(); + const courseTz = role.classes.time_zone || "America/New_York"; + const { displayTimeZone } = useTimeZonePreference(role.class_id, courseTz); if (!dueDate || !originalDueDate) { return ; } @@ -216,13 +217,11 @@ export function AssignmentDueDate({ {showDue && Due: } - {formatInTimeZone(new TZDate(dueDate, time_zone), time_zone, "MMM d h:mm aaa")} + {formatInTimeZone(new TZDate(dueDate, displayTimeZone), displayTimeZone, "MMM d h:mm aaa")} + + + ({displayTimeZone}) - {showTimeZone && ( - - ({time_zone}) - - )} {hoursExtended > 0 && ( ({hoursExtended}-hour extension applied, {lateTokensConsumed} late tokens consumed) @@ -234,17 +233,14 @@ export function AssignmentDueDate({ ); } -export function SelfReviewDueDate({ - assignment, - showTimeZone = false -}: { - assignment: AssignmentsForStudentDashboard; - showTimeZone?: boolean; -}) { +export function SelfReviewDueDate({ assignment }: { assignment: AssignmentsForStudentDashboard }) { const { private_profile_id } = useClassProfiles(); - const { dueDate, originalDueDate, time_zone } = useAssignmentDueDate(assignment as Assignment, { + const { dueDate, originalDueDate } = useAssignmentDueDate(assignment as Assignment, { studentPrivateProfileId: private_profile_id }); + const { role } = useClassProfiles(); + const courseTz = role.classes.time_zone || "America/New_York"; + const { displayTimeZone } = useTimeZonePreference(role.class_id, courseTz); if (!dueDate || !originalDueDate) { return ; } @@ -253,11 +249,11 @@ export function SelfReviewDueDate({ {formatInTimeZone( new TZDate(addHours(dueDate, assignment.self_review_deadline_offset ?? 0)), - time_zone || "America/New_York", + displayTimeZone, "MMM d h:mm aaa" )} - {showTimeZone && ({time_zone})} + ({displayTimeZone}) ); } diff --git a/components/ui/self-review-notice.tsx b/components/ui/self-review-notice.tsx index 39effee7..f63f986f 100644 --- a/components/ui/self-review-notice.tsx +++ b/components/ui/self-review-notice.tsx @@ -11,6 +11,7 @@ import Link from "next/link"; import { useParams } from "next/navigation"; import { useState } from "react"; import { FaExclamationTriangle } from "react-icons/fa"; +import { useTimeZonePreference } from "@/hooks/useTimeZonePreference"; function CompleteReviewButton({ assignment, @@ -64,6 +65,7 @@ function SelfReviewNoticeInner({ activeSubmission?: Submission; }) { const { dueDate, time_zone } = useAssignmentDueDate(assignment); + const { displayTimeZone } = useTimeZonePreference(assignment.class_id, time_zone || "America/New_York"); const myReviewAssignments = useMyReviewAssignments(); const selfReviewRubric = useRubric("self-review"); const selfReviewAssignment = myReviewAssignments.find((a) => a.rubric_id === selfReviewRubric?.id); @@ -92,7 +94,7 @@ function SelfReviewNoticeInner({ Self Review Now Due - Due by {formatInTimeZone(evalDeadline, time_zone || "America/New_York", "MMM d h:mm aaa")} ({time_zone}) + Due by {formatInTimeZone(evalDeadline, displayTimeZone, "MMM d h:mm aaa")} ({displayTimeZone}) void; +}; + +// Client-side cookie access is intentionally avoided; cookies are written server-side +// via a Route Handler for better security and consistency with Next.js best practices. + +/** + * Returns the browser's current IANA time zone (e.g., "America/Los_Angeles"). + */ +function getBrowserTimeZone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return "UTC"; + } +} + +/** + * Provides a cookie-backed user preference for displaying dates in either the course + * time zone or the browser's current time zone, on a per-course basis. + */ +export function useTimeZonePreference( + courseId: number, + courseTimeZone: string, + initialPreference?: TimeZonePreference +): UseTimeZonePreferenceReturn { + const browserTimeZone = useMemo(() => getBrowserTimeZone(), []); + + // Default to "course" until we learn otherwise from the API. + const [preference, setPreference] = useState(initialPreference ?? "course"); + + // Tri-state internal flag: undefined while loading, then true/false accordingly. + const [hasCookie, setHasCookie] = useState(initialPreference ? true : undefined); + + // Initial load: query the server for an existing preference cookie + useEffect(() => { + let isActive = true; + const controller = new AbortController(); + + async function fetchPreference(): Promise { + try { + const res = await fetch(`/api/timezone-preference?courseId=${courseId}`, { + method: "GET", + signal: controller.signal, + credentials: "same-origin", + headers: { Accept: "application/json" } + }); + if (!res.ok) { + if (isActive) { + setHasCookie(false); + } + return; + } + const data = (await res.json()) as Partial<{ preference: TimeZonePreference }>; + if (!isActive) return; + if (data.preference === "course" || data.preference === "browser") { + setPreference(data.preference); + setHasCookie(true); + } else { + setHasCookie(false); + } + } catch { + if (isActive) setHasCookie(false); + } + } + + fetchPreference(); + return () => { + isActive = false; + controller.abort(); + }; + }, [courseId]); + + const setPreferenceChoice = useCallback( + async (choice: TimeZonePreference) => { + try { + // Optimistically update the UI + setPreference(choice); + setHasCookie(true); + + await fetch(`/api/timezone-preference`, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ courseId, choice }) + }); + } catch { + // If the server write fails, we still keep the local state consistent for UX. + // A subsequent navigation/refresh will re-attempt the GET and reconcile. + } + }, + [courseId] + ); + + const displayTimeZone = useMemo( + () => (preference === "browser" ? browserTimeZone : courseTimeZone), + [preference, browserTimeZone, courseTimeZone] + ); + + const shouldPrompt = useMemo(() => { + // Do not prompt until we know whether a cookie exists + if (hasCookie === undefined) return false; + if (hasCookie) return false; + return courseTimeZone !== browserTimeZone; + }, [hasCookie, courseTimeZone, browserTimeZone]); + + return { + displayTimeZone, + preference, + courseTimeZone, + browserTimeZone, + shouldPrompt, + setPreferenceChoice + }; +}