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
+ };
+}