moving v2 to top level

This commit is contained in:
2024-12-17 09:19:21 -07:00
parent 5f0b3554dc
commit 576ee02afb
468 changed files with 79 additions and 15430 deletions

View File

@@ -0,0 +1,69 @@
"use client";
import { CalendarMonthModel, getWeekNumber } from "./calendarMonthUtils";
import { DayOfWeek } from "@/models/local/localCourseSettings";
import { Expandable } from "@/components/Expandable";
import { CalendarWeek } from "./CalendarWeek";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils";
export const CalendarMonth = ({ month }: { month: CalendarMonthModel }) => {
// const weekInMilliseconds = 604_800_000;
const four_days_in_milliseconds = 345_600_000;
const [settings] = useLocalCourseSettingsQuery();
const startDate = getDateFromStringOrThrow(
settings.startDate,
"week calculation start date"
);
const pastWeekNumber = getWeekNumber(
startDate,
new Date(Date.now() - four_days_in_milliseconds)
);
const startOfMonthWeekNumber = getWeekNumber(
startDate,
new Date(month.year, month.month, 1)
);
const isInPast = pastWeekNumber >= startOfMonthWeekNumber;
const monthName = new Date(month.year, month.month - 1, 1).toLocaleString(
"default",
{ month: "long" }
);
const weekDaysList: DayOfWeek[] = Object.values(DayOfWeek);
return (
<>
<Expandable
defaultExpanded={!isInPast}
ExpandableElement={({ setIsExpanded }) => (
<div className="flex justify-center">
<h3
className={
"text-2xl transition-all duration-500 " +
"hover:text-slate-50 underline hover:scale-105 "
}
onClick={() => setIsExpanded((e) => !e)}
role="button"
>
{monthName}
</h3>
</div>
)}
>
<div className="grid grid-cols-7 text-center fw-bold ms-3">
{weekDaysList.map((day) => (
<div key={day} className={""}>
<span className="hidden xl:inline">{day}</span>
<span className="xl:hidden inline">{day.slice(0, 3)}</span>
</div>
))}
</div>
{month.daysByWeek.map((week, weekIndex) => (
<CalendarWeek key={weekIndex} week={week} monthNumber={month.month} />
))}
</Expandable>
</>
);
};

View File

@@ -0,0 +1,36 @@
"use client";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils";
import { getWeekNumber } from "./calendarMonthUtils";
import Day from "./day/Day";
export function CalendarWeek({
week,
monthNumber,
}: {
week: string[]; //date strings
monthNumber: number;
}) {
const [settings]= useLocalCourseSettingsQuery();
const startDate = getDateFromStringOrThrow(
settings.startDate,
"week calculation start date"
);
const firstDateString = getDateFromStringOrThrow(
week[0],
"week calculation first day of week"
);
const weekNumber = getWeekNumber(startDate, firstDateString);
return (
<div className="flex flex-row">
<div className="my-auto text-gray-400 w-6 sm:block hidden">
{weekNumber.toString().padStart(2, "0")}
</div>
<div className="grid grid-cols-7 grow">
{week.map((day, dayIndex) => (
<Day key={dayIndex} day={day} month={monthNumber} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils";
import { getMonthsBetweenDates } from "./calendarMonthUtils";
import { CalendarMonth } from "./CalendarMonth";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { useMemo } from "react";
import CalendarItemsContextProvider from "../context/CalendarItemsContextProvider";
export default function CourseCalendar() {
const [settings] = useLocalCourseSettingsQuery();
const startDateTime = useMemo(
() => getDateFromStringOrThrow(settings.startDate, "course start date"),
[settings.startDate]
);
const endDateTime = useMemo(
() => getDateFromStringOrThrow(settings.endDate, "course end date"),
[settings.endDate]
);
const months = useMemo(
() => getMonthsBetweenDates(startDateTime, endDateTime),
[endDateTime, startDateTime]
);
return (
<div
className="
min-h-0
flex-grow
border-4
border-gray-900
rounded-lg
bg-slate-950
sm:p-1
"
>
<div className="h-full overflow-y-scroll sm:pe-1">
<CalendarItemsContextProvider>
{months.map((month) => (
<CalendarMonth key={month.month + "" + month.year} month={month} />
))}
</CalendarItemsContextProvider>
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import {
dateToMarkdownString,
getDateFromStringOrThrow,
} from "@/models/local/utils/timeUtils";
export interface CalendarMonthModel {
year: number;
month: number;
weeks: number[][];
daysByWeek: string[][]; //iso date is memo-izable
}
function weeksInMonth(year: number, month: number): number {
const firstDayOfMonth = new Date(year, month - 1, 1).getDay();
const daysInMonth = new Date(year, month, 0).getDate();
const longDaysInMonth = daysInMonth + firstDayOfMonth;
let weeks = Math.floor(longDaysInMonth / 7);
if (longDaysInMonth % 7 > 0) {
weeks += 1;
}
return weeks;
}
function createCalendarMonth(year: number, month: number): CalendarMonthModel {
const weeksNumber = weeksInMonth(year, month);
const daysInMonth = new Date(year, month, 0).getDate();
let currentDay = 1;
const firstDayOfMonth = new Date(year, month - 1, 1).getDay();
const daysByWeek = Array.from({ length: weeksNumber })
.map((_, weekIndex) =>
Array.from({ length: 7 }).map((_, dayIndex) => {
if (weekIndex === 0 && dayIndex < firstDayOfMonth) {
return dateToMarkdownString(
new Date(year, month - 1, dayIndex - firstDayOfMonth + 1, 12, 0, 0)
);
} else if (currentDay <= daysInMonth) {
return dateToMarkdownString(
new Date(year, month - 1, currentDay++, 12, 0, 0)
);
} else {
currentDay++;
return dateToMarkdownString(
new Date(year, month, currentDay - daysInMonth - 1, 12, 0, 0)
);
}
})
)
.filter((week) => {
const lastDate = getDateFromStringOrThrow(
week.at(-1)!,
"filtering out last week of month"
);
return lastDate.getMonth() <= month - 1;
});
const weeks = daysByWeek.map((week) =>
week.map((day) =>
getDateFromStringOrThrow(day, "calculating weeks").getDate()
)
);
return { year, month, weeks, daysByWeek };
}
export function getMonthsBetweenDates(
startDate: Date,
endDate: Date
): CalendarMonthModel[] {
const monthsInTerm =
1 +
(endDate.getFullYear() - startDate.getFullYear()) * 12 +
endDate.getMonth() -
startDate.getMonth();
return Array.from({ length: monthsInTerm }, (_, monthDiff) => {
const month = ((startDate.getMonth() + monthDiff) % 12) + 1;
const year =
startDate.getFullYear() +
Math.floor((startDate.getMonth() + monthDiff) / 12);
return createCalendarMonth(year, month);
});
}
export const getWeekNumber = (startDate: Date, currentDate: Date) => {
const sundayBeforeStartDate = getPreviousSunday(startDate);
const daysBetween = daysBetweenDates(sundayBeforeStartDate, currentDate);
const weeksDiff = Math.floor(daysBetween / 7);
if (weeksDiff >= 0) return weeksDiff + 1;
return weeksDiff;
};
const daysBetweenDates = (startDate: Date, endDate: Date) => {
const diffInTime = endDate.getTime() - startDate.getTime();
const diffInDays = diffInTime / (1000 * 3600 * 24);
return Math.floor(diffInDays);
};
const getPreviousSunday = (date: Date) => {
const result = new Date(date);
const dayOfWeek = result.getDay();
result.setDate(result.getDate() - dayOfWeek);
return result;
};

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { getWeekNumber } from "./calendarMonthUtils";
// months are 0 based, days are 1 based
describe("testing week numbers", () => {
it("can get before first day", () => {
const startDate = new Date(2024, 8, 3);
const firstDayOfFirstWeek = new Date(2024, 8, 1);
const weekNumber = getWeekNumber(startDate, firstDayOfFirstWeek);
expect(weekNumber).toBe(1);
});
it("can get end of first week", () => {
const startDate = new Date(2024, 8, 3);
const firstDayOfFirstWeek = new Date(2024, 8, 7);
const weekNumber = getWeekNumber(startDate, firstDayOfFirstWeek);
expect(weekNumber).toBe(1);
});
it("can get start of second week", () => {
const startDate = new Date(2024, 8, 3);
const firstDayOfFirstWeek = new Date(2024, 8, 8);
const weekNumber = getWeekNumber(startDate, firstDayOfFirstWeek);
expect(weekNumber).toBe(2);
});
it("can get start of third week", () => {
const startDate = new Date(2024, 8, 3);
const firstDayOfFirstWeek = new Date(2024, 8, 15);
const weekNumber = getWeekNumber(startDate, firstDayOfFirstWeek);
expect(weekNumber).toBe(3);
});
it("can get previous week", () => {
const startDate = new Date(2024, 8, 3);
const firstDayOfFirstWeek = new Date(2024, 7, 29);
const weekNumber = getWeekNumber(startDate, firstDayOfFirstWeek);
expect(weekNumber).toBe(-1);
});
});

View File

@@ -0,0 +1,126 @@
"use client";
import {
getDateFromStringOrThrow,
getDateOnlyMarkdownString,
} from "@/models/local/utils/timeUtils";
import { useDraggingContext } from "../../context/drag/draggingContext";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { getDayOfWeek } from "@/models/local/localCourseSettings";
import { ItemInDay } from "./ItemInDay";
import { useTodaysItems } from "./useTodaysItems";
import { DayTitle } from "./DayTitle";
export default function Day({ day, month }: { day: string; month: number }) {
const dayAsDate = getDateFromStringOrThrow(
day,
"calculating same month in day"
);
const isToday =
getDateOnlyMarkdownString(new Date()) ===
getDateOnlyMarkdownString(dayAsDate);
const [settings] = useLocalCourseSettingsQuery();
const { itemDropOnDay } = useDraggingContext();
const { todaysAssignments, todaysQuizzes, todaysPages } = useTodaysItems(day);
const isInSameMonth = dayAsDate.getMonth() + 1 == month;
const classOnThisDay = settings.daysOfWeek.includes(getDayOfWeek(dayAsDate));
// maybe this is slow?
const holidayNameToday = settings.holidays.reduce(
(holidaysHappeningToday, holiday) => {
const holidayDates = holiday.days.map((d) =>
getDateOnlyMarkdownString(
getDateFromStringOrThrow(d, "holiday date in day component")
)
);
const today = getDateOnlyMarkdownString(dayAsDate);
if (holidayDates.includes(today))
return [...holidaysHappeningToday, holiday.name];
return holidaysHappeningToday;
},
[] as string[]
);
const semesterStart = getDateFromStringOrThrow(
settings.startDate,
"comparing start date in day"
);
const semesterEnd = getDateFromStringOrThrow(
settings.endDate,
"comparing end date in day"
);
const isInSemester = semesterStart < dayAsDate && semesterEnd > dayAsDate;
const meetingClasses =
classOnThisDay && isInSemester && holidayNameToday.length === 0
? " bg-slate-900 "
: " ";
const todayClasses = isToday
? " border border-blue-700 shadow-[0_0px_10px_0px] shadow-blue-500/50 "
: " ";
const monthClass =
isInSameMonth && !isToday ? " border border-slate-700 " : " ";
return (
<div
className={
" rounded-lg sm:m-1 m-0.5 min-h-10 " +
meetingClasses +
monthClass +
todayClasses
}
onDrop={(e) => itemDropOnDay(e, day)}
onDragOver={(e) => e.preventDefault()}
>
<div className="draggingDay flex flex-col">
<DayTitle day={day} dayAsDate={dayAsDate} />
<div className="flex-grow">
{todaysAssignments.map(
({ assignment, moduleName, status, message }) => (
<ItemInDay
key={assignment.name}
type={"assignment"}
moduleName={moduleName}
item={assignment}
status={status}
message={message}
/>
)
)}
{todaysQuizzes.map(({ quiz, moduleName, status, message }) => (
<ItemInDay
key={quiz.name}
type={"quiz"}
moduleName={moduleName}
item={quiz}
status={status}
message={message}
/>
))}
{todaysPages.map(({ page, moduleName, status, message }) => (
<ItemInDay
key={page.name}
type={"page"}
moduleName={moduleName}
item={page}
status={status}
message={message}
/>
))}
</div>
<div>
{holidayNameToday.map((n) => (
<div key={n} className="font-extrabold text-blue-100 text-center">
{n}
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import Modal, { useModal } from "@/components/Modal";
import { getLectureUrl } from "@/services/urlUtils";
import Link from "next/link";
import { useCourseContext } from "../../context/courseContext";
import NewItemForm from "../../modules/NewItemForm";
import { DraggableItem } from "../../context/drag/draggingContext";
import { useDragStyleContext } from "../../context/drag/dragStyleContext";
import { getLectureForDay } from "@/models/local/utils/lectureUtils";
import { useLecturesSuspenseQuery } from "@/hooks/localCourse/lectureHooks";
export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
const { courseName } = useCourseContext();
const [weeks] = useLecturesSuspenseQuery();
const { setIsDragging } = useDragStyleContext();
const todaysLecture = getLectureForDay(weeks, dayAsDate);
const modal = useModal();
const lectureName = todaysLecture && (todaysLecture.name || "lecture");
return (
<div className="flex justify-between">
<Link
className="ms-1 me-1 truncate text-nowrap transition-all hover:font-bold hover:text-slate-300"
href={getLectureUrl(courseName, day)}
prefetch={false}
draggable={true}
onDragStart={(e) => {
if (todaysLecture) {
const draggableItem: DraggableItem = {
type: "lecture",
item: { ...todaysLecture, dueAt: todaysLecture.date },
sourceModuleName: undefined,
};
e.dataTransfer.setData(
"draggableItem",
JSON.stringify(draggableItem)
);
setIsDragging(true);
}
}}
>
{dayAsDate.getDate()} {lectureName}
</Link>
<Modal
modalControl={modal}
buttonText="+"
buttonClass="unstyled hover:font-bold hover:scale-125 px-1 mb-auto mt-0 pt-0"
>
{({ closeModal }) => (
<div>
<NewItemForm creationDate={day} onCreate={closeModal} />
<br />
<button onClick={closeModal}>close</button>
</div>
)}
</Modal>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { IModuleItem } from "@/models/local/IModuleItem";
import { getModuleItemUrl } from "@/services/urlUtils";
import Link from "next/link";
import { ReactNode, useEffect, useRef, useState } from "react";
import { useCourseContext } from "../../context/courseContext";
import {
useDraggingContext,
DraggableItem,
} from "../../context/drag/draggingContext";
import { createPortal } from "react-dom";
import ClientOnly from "@/components/ClientOnly";
import { useDragStyleContext } from "../../context/drag/dragStyleContext";
export function ItemInDay({
type,
moduleName,
status,
item,
message,
}: {
type: "assignment" | "page" | "quiz";
status: "localOnly" | "incomplete" | "published";
moduleName: string;
item: IModuleItem;
message: ReactNode;
}) {
const { courseName } = useCourseContext();
const { setIsDragging } = useDragStyleContext();
const linkRef = useRef<HTMLAnchorElement>(null);
const [tooltipVisible, setTooltipVisible] = useState(false);
return (
<div className={" relative group "}>
<Link
href={getModuleItemUrl(courseName, moduleName, type, item.name)}
shallow={true}
className={
" border rounded-sm sm:px-1 sm:mx-1 break-words mb-1 truncate sm:text-wrap text-nowrap " +
" bg-slate-800 " +
" block " +
(status === "localOnly" && " text-slate-500 border-slate-600 ") +
(status === "incomplete" && " border-rose-900 ") +
(status === "published" && " border-green-800 ")
}
role="button"
draggable="true"
onDragStart={(e) => {
const draggableItem: DraggableItem = {
type,
item,
sourceModuleName: moduleName,
};
e.dataTransfer.setData(
"draggableItem",
JSON.stringify(draggableItem)
);
setIsDragging(true)
}}
onMouseEnter={() => setTooltipVisible(true)}
onMouseLeave={() => setTooltipVisible(false)}
ref={linkRef}
>
{item.name}
</Link>
<ClientOnly>
<Tooltip
message={message}
targetRef={linkRef}
visible={tooltipVisible && status === "incomplete"}
/>
</ClientOnly>
</div>
);
}
const Tooltip: React.FC<{
message: ReactNode;
targetRef: React.RefObject<HTMLElement>;
visible: boolean;
}> = ({ message, targetRef, visible }) => {
const rect = targetRef.current?.getBoundingClientRect();
return createPortal(
<div
style={{
top: (rect?.bottom ?? 0) + window.scrollY + 10,
left: (rect?.left ?? 0) + window.scrollX + (rect?.width ?? 0) / 2,
}}
className={
" absolute -translate-x-1/2 " +
" bg-gray-800 text-white text-sm " +
" rounded py-1 px-2 " +
" transition-all duration-400 " +
" border border-slate-700 shadow-[0_0px_10px_0px] shadow-slate-500/50 " +
(visible ? " " : " hidden -z-50 ")
}
role="tooltip"
>
{message}
</div>,
document.body
);
};

View File

@@ -0,0 +1,119 @@
"use client";
import { CanvasAssignment } from "@/models/canvas/assignments/canvasAssignment";
import { CanvasPage } from "@/models/canvas/pages/canvasPageModel";
import { CanvasQuiz } from "@/models/canvas/quizzes/canvasQuizModel";
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
import { LocalCourseSettings } from "@/models/local/localCourseSettings";
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
import {
dateToMarkdownString,
getDateFromStringOrThrow,
} from "@/models/local/utils/timeUtils";
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
import { htmlIsCloseEnough } from "@/services/utils/htmlIsCloseEnough";
import { ReactNode } from "react";
export const getStatus = ({
item,
canvasItem,
type,
settings,
}: {
item: LocalQuiz | LocalAssignment | LocalCoursePage;
canvasItem?: CanvasQuiz | CanvasAssignment | CanvasPage;
type: "assignment" | "page" | "quiz";
settings: LocalCourseSettings;
}): {
status: "localOnly" | "incomplete" | "published";
message: ReactNode;
} => {
if (!canvasItem) return { status: "localOnly", message: "not in canvas" };
if (!canvasItem.published)
return { status: "incomplete", message: "not published in canvas" };
if (type === "page") {
const canvasPage = canvasItem as CanvasPage;
const page = item as LocalCoursePage;
if (!canvasPage.published)
return { status: "incomplete", message: "canvas page not published" };
return { status: "published", message: "" };
} else if (type === "quiz") {
const quiz = item as LocalQuiz;
const canvasQuiz = canvasItem as CanvasQuiz;
if (!canvasQuiz.due_at)
return { status: "incomplete", message: "due date not in canvas" };
if (quiz.lockAt && !canvasQuiz.lock_at)
return { status: "incomplete", message: "lock date not in canvas" };
const localDueDate = dateToMarkdownString(
getDateFromStringOrThrow(quiz.dueAt, "comparing due dates for day")
);
const canvasDueDate = dateToMarkdownString(
getDateFromStringOrThrow(
canvasQuiz.due_at,
"comparing canvas due date for day"
)
);
if (localDueDate !== canvasDueDate) {
return {
status: "incomplete",
message: (
<div>
due date different
<div>{localDueDate}</div>
<div>{canvasDueDate}</div>
</div>
),
};
}
} else if (type === "assignment") {
const assignment = item as LocalAssignment;
const canvasAssignment = canvasItem as CanvasAssignment;
if (!canvasAssignment.due_at)
return { status: "incomplete", message: "due date not in canvas" };
if (assignment.lockAt && !canvasAssignment.lock_at)
return { status: "incomplete", message: "lock date not in canvas" };
const localDueDate = dateToMarkdownString(
getDateFromStringOrThrow(assignment.dueAt, "comparing due dates for day")
);
const canvasDueDate = dateToMarkdownString(
getDateFromStringOrThrow(
canvasAssignment.due_at,
"comparing canvas due date for day"
)
);
if (localDueDate !== canvasDueDate)
return {
status: "incomplete",
message: (
<div>
due date different
<div>{localDueDate}</div>
<div>{canvasDueDate}</div>
</div>
),
};
const htmlIsSame = htmlIsCloseEnough(
markdownToHTMLSafe(assignment.description, settings),
canvasAssignment.description
);
if (!htmlIsSame)
return {
status: "incomplete",
message: "Canvas description is different",
};
}
return { status: "published", message: "" };
};

View File

@@ -0,0 +1,101 @@
"use client";
import { useCanvasAssignmentsQuery } from "@/hooks/canvas/canvasAssignmentHooks";
import { useCanvasPagesQuery } from "@/hooks/canvas/canvasPageHooks";
import { useCanvasQuizzesQuery } from "@/hooks/canvas/canvasQuizHooks";
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
import {
getDateFromStringOrThrow,
getDateOnlyMarkdownString,
} from "@/models/local/utils/timeUtils";
import { ReactNode } from "react";
import { useCalendarItemsContext } from "../../context/calendarItemsContext";
import { getStatus } from "./getStatus";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
export function useTodaysItems(day: string) {
const [settings] = useLocalCourseSettingsQuery();
const dayAsDate = getDateFromStringOrThrow(
day,
"calculating same month in day items"
);
const itemsContext = useCalendarItemsContext();
const dateKey = getDateOnlyMarkdownString(dayAsDate);
const todaysModules = itemsContext[dateKey];
const { data: canvasAssignments } = useCanvasAssignmentsQuery();
const { data: canvasQuizzes } = useCanvasQuizzesQuery();
const { data: canvasPages } = useCanvasPagesQuery();
const todaysAssignments: {
moduleName: string;
assignment: LocalAssignment;
status: "localOnly" | "incomplete" | "published";
message: ReactNode;
}[] = todaysModules
? Object.keys(todaysModules).flatMap((moduleName) =>
todaysModules[moduleName].assignments.map((assignment) => {
const canvasAssignment = canvasAssignments?.find(
(c) => c.name === assignment.name
);
return {
moduleName,
assignment,
...getStatus({
item: assignment,
canvasItem: canvasAssignment,
type: "assignment",
settings,
}),
};
})
)
: [];
const todaysQuizzes: {
moduleName: string;
quiz: LocalQuiz;
status: "localOnly" | "incomplete" | "published";
message: ReactNode;
}[] = todaysModules
? Object.keys(todaysModules).flatMap((moduleName) =>
todaysModules[moduleName].quizzes.map((quiz) => {
const canvasQuiz = canvasQuizzes?.find((q) => q.title === quiz.name);
return {
moduleName,
quiz,
...getStatus({
item: quiz,
canvasItem: canvasQuiz,
type: "quiz",
settings,
}),
};
})
)
: [];
const todaysPages: {
moduleName: string;
page: LocalCoursePage;
status: "localOnly" | "incomplete" | "published";
message: ReactNode;
}[] = todaysModules
? Object.keys(todaysModules).flatMap((moduleName) =>
todaysModules[moduleName].pages.map((page) => {
const canvasPage = canvasPages?.find((p) => p.title === page.name);
return {
moduleName,
page,
...getStatus({
item: page,
canvasItem: canvasPage,
type: "page",
settings,
}),
};
})
)
: [];
return { todaysAssignments, todaysQuizzes, todaysPages };
}