diff --git a/nextjs/src/app/course/[courseName]/settings/HolidayConfig.tsx b/nextjs/src/app/course/[courseName]/settings/HolidayConfig.tsx index 6219f6b..22b57fd 100644 --- a/nextjs/src/app/course/[courseName]/settings/HolidayConfig.tsx +++ b/nextjs/src/app/course/[courseName]/settings/HolidayConfig.tsx @@ -1,15 +1,22 @@ "use client"; import TextInput from "@/components/form/TextInput"; +import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling"; import { useLocalCourseSettingsQuery, useUpdateLocalCourseSettingsMutation, } from "@/hooks/localCourse/localCoursesHooks"; import { dateToMarkdownString, + getDateFromString, getDateFromStringOrThrow, + getDateOnlyMarkdownString, } from "@/models/local/timeUtils"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { + holidaysToString, + parseHolidays, +} from "../../../../models/local/settingsUtils"; const exampleString = `springBreak: - 10/12/2024 @@ -18,68 +25,126 @@ const exampleString = `springBreak: laborDay: - 9/1/2024`; +export const holidaysAreEqual = ( + obj1: { [key: string]: string[] }, + obj2: { [key: string]: string[] } +): boolean => { + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + + if (keys1.length !== keys2.length) return false; + + for (const key of keys1) { + if (!obj2.hasOwnProperty(key)) return false; + + const arr1 = obj1[key]; + const arr2 = obj2[key]; + + if (arr1.length !== arr2.length) return false; + + const sortedArr1 = [...arr1].sort(); + const sortedArr2 = [...arr2].sort(); + + for (let i = 0; i < sortedArr1.length; i++) { + if (sortedArr1[i] !== sortedArr2[i]) return false; + } + } + + return true; +}; + export default function HolidayConfig() { + return ( + + + + ); +} +function InnerHolidayConfig() { const { data: settings } = useLocalCourseSettingsQuery(); const updateSettings = useUpdateLocalCourseSettingsMutation(); - const [rawText, setRawText] = useState(""); + const [rawText, setRawText] = useState(holidaysToString(settings.holidays)); - const parsedText = parseHolidays(rawText); + useEffect(() => { + const id = setTimeout(() => { + try { + const parsed = parseHolidays(rawText); + + if (!holidaysAreEqual(settings.holidays, parsed)) + updateSettings.mutate({ + ...settings, + holidays: parsed, + }); + } catch (error: any) {} + }, 500); + return () => clearTimeout(id); + }, [rawText, settings, updateSettings]); return ( -
- -
- Format your holidays like so: -
-          {exampleString}
-        
+
+
+ +
+ Format your holidays like so: +
+            {exampleString}
+          
+
- {Object.keys(parsedText).map((k) => ( -
-
{k}
-
- {parsedText[k].map((day) => { - const parsedDate = getDateFromStringOrThrow( - day, - "holiday preview display" - ); - return
{dateToMarkdownString(parsedDate)}
; - })} -
-
- ))} + + +
); } -const parseHolidays = ( - inputText: string -): { [holidayName: string]: string[] } => { - const holidays: { [holidayName: string]: string[] } = {}; - - const lines = inputText.split("\n").filter(line => line.trim() !== ""); - let currentHoliday: string | null = null; +function ParsedHolidaysDisplay({ value }: { value: string }) { + const [parsedHolidays, setParsedHolidays] = useState<{ + [holidayName: string]: string[]; + }>({}); + const [error, setError] = useState(""); - lines.forEach(line => { - if (line.includes(":")) { - // It's a holiday name - const holidayName = line.split(":")[0].trim(); - currentHoliday = holidayName; - holidays[currentHoliday] = []; - } else if (currentHoliday && line.startsWith("-")) { - // It's a date under the current holiday - const date = line.replace("-", "").trim(); - holidays[currentHoliday].push(date); - } - }); + useEffect(() => { + try { + const parsed = parseHolidays(value); + setParsedHolidays(parsed); + setError(""); + } catch (error: any) { + setError(error + ""); + } + }, [value]); - return holidays; -}; + return ( +
+
{error}
+ {Object.keys(parsedHolidays).map((k) => ( +
+
{k}
+
+ {parsedHolidays[k].map((day) => { + const date = getDateFromString(day); + return ( +
+ {date?.toLocaleDateString("en-us", { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + })} +
+ ); + })} +
+
+ ))} +
+ ); +} diff --git a/nextjs/src/app/course/[courseName]/settings/page.tsx b/nextjs/src/app/course/[courseName]/settings/page.tsx index 152cac3..c10a59f 100644 --- a/nextjs/src/app/course/[courseName]/settings/page.tsx +++ b/nextjs/src/app/course/[courseName]/settings/page.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { useCourseContext } from "../context/courseContext"; import StartAndEndDate from "./StartAndEndDate"; import SettingsHeader from "./SettingsHeader"; import DefaultDueTime from "./DefaultDueTime"; diff --git a/nextjs/src/models/local/settingsUtils.tsx b/nextjs/src/models/local/settingsUtils.tsx new file mode 100644 index 0000000..4f813ca --- /dev/null +++ b/nextjs/src/models/local/settingsUtils.tsx @@ -0,0 +1,40 @@ +import { + dateToMarkdownString, + getDateFromString, + getDateFromStringOrThrow, + getDateOnlyMarkdownString, +} from "./timeUtils"; + +export const parseHolidays = ( + inputText: string +): { [holidayName: string]: string[] } => { + const holidays: { [holidayName: string]: string[] } = {}; + + const lines = inputText.split("\n").filter((line) => line.trim() !== ""); + let currentHoliday: string | null = null; + + lines.forEach((line) => { + if (line.includes(":")) { + const holidayName = line.split(":")[0].trim(); + currentHoliday = holidayName; + holidays[currentHoliday] = []; + } else if (currentHoliday && line.startsWith("-")) { + const date = line.replace("-", "").trim(); + const dateObject = getDateFromStringOrThrow(date, "parsing holiday text"); + holidays[currentHoliday].push(getDateOnlyMarkdownString(dateObject)); + } + }); + + return holidays; +}; + + +export const holidaysToString = (holidays: { [holidayName: string]: string[] })=> { + const entries = Object.keys(holidays).map(holiday => { + const title = holiday + ":\n" + const days = holidays[holiday].map(d => `- ${d}\n`) + return title + days.join("") + }) + + return entries.join("") +} \ No newline at end of file diff --git a/nextjs/src/models/local/tests/testHolidayParsing.test.ts b/nextjs/src/models/local/tests/testHolidayParsing.test.ts new file mode 100644 index 0000000..aac45d0 --- /dev/null +++ b/nextjs/src/models/local/tests/testHolidayParsing.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { parseHolidays } from "../settingsUtils"; + +describe("can parse holiday string", () => { + it("can parse empty list", () => { + const testString = ` +springBreak: +`; + const output = parseHolidays(testString); + expect(output).toEqual({ springBreak: [] }); + }); + it("can parse list with date", () => { + const testString = ` +springBreak: +- 10/12/2024 +`; + const output = parseHolidays(testString); + expect(output).toEqual({ springBreak: ["10/12/2024"] }); + }); + it("can parse list with two dates", () => { + const testString = ` +springBreak: +- 10/12/2024 +- 10/13/2024 +`; + const output = parseHolidays(testString); + expect(output).toEqual({ springBreak: ["10/12/2024", "10/13/2024"] }); + }); +}); diff --git a/nextjs/src/models/local/tests/timeUtils.test.ts b/nextjs/src/models/local/tests/timeUtils.test.ts index 0b0e501..0103f44 100644 --- a/nextjs/src/models/local/tests/timeUtils.test.ts +++ b/nextjs/src/models/local/tests/timeUtils.test.ts @@ -61,4 +61,12 @@ describe("Can properly handle expected date formats", () => { expect(updatedString).toBe("08/29/2024 17:00:00") }) + it("can handle date without time", () => { + const dateString = "8/29/2024"; + const dateObject = getDateFromString(dateString); + + expect(dateObject).not.toBeUndefined() + const updatedString = dateToMarkdownString(dateObject!) + expect(updatedString).toBe("08/29/2024 00:00:00") + }) }); diff --git a/nextjs/src/models/local/timeUtils.ts b/nextjs/src/models/local/timeUtils.ts index 6da28a3..76547f4 100644 --- a/nextjs/src/models/local/timeUtils.ts +++ b/nextjs/src/models/local/timeUtils.ts @@ -36,11 +36,18 @@ const _getDateFromISO = (value: string): Date | undefined => { return isNaN(date.getTime()) ? undefined : date; }; +const _getDateFromDateOnly = (datePart: string): Date | undefined => { + const [month, day, year] = datePart.split("/").map(Number); + const date = new Date(year, month - 1, day); + return isNaN(date.getTime()) ? undefined : date; +}; + export const getDateFromString = (value: string): Date | undefined => { const ampmDateRegex = /^\d{1,2}\/\d{1,2}\/\d{4},? \d{1,2}:\d{2}:\d{2}\s{1}[APap][Mm]$/; //"M/D/YYYY h:mm:ss AM/PM" or "M/D/YYYY, h:mm:ss AM/PM" const militaryDateRegex = /^\d{1,2}\/\d{1,2}\/\d{4} \d{1,2}:\d{2}:\d{2}$/; //"MM/DD/YYYY HH:mm:ss" const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}((.\d+)|(Z))$/; //"2024-08-26T00:00:00.0000000" + const dateOnlyRegex = /^\d{1,2}\/\d{1,2}\/\d{4}$/; // "M/D/YYYY" or "MM/DD/YYYY" if (isoDateRegex.test(value)) { return _getDateFromISO(value); @@ -50,6 +57,8 @@ export const getDateFromString = (value: string): Date | undefined => { } else if (militaryDateRegex.test(value)) { const [datePart, timePart] = value.split(" "); return _getDateFromMilitary(datePart, timePart); + } if (dateOnlyRegex.test(value)) { + return _getDateFromDateOnly(value); } else { if (value) console.log("invalid date format", value); return undefined;