From 924c93271b82143e2b4ad5565820eb054a15ef88 Mon Sep 17 00:00:00 2001 From: Michael Zhang Date: Thu, 24 Aug 2023 13:37:23 -0400 Subject: [PATCH] insane week behavior --- lib/ScrollContainer.module.scss | 24 ++++ lib/ScrollContainer.tsx | 164 ++++++++++++++++++------- lib/{month.ts => date.ts} | 8 ++ lib/persistentState.ts | 15 +++ src/calendar/Calendar.tsx | 69 +++++++++-- src/calendar/Month.module.scss | 3 - src/calendar/Month.tsx | 2 +- src/calendar/MonthCalendar.module.scss | 8 ++ src/calendar/MonthCalendar.tsx | 5 +- src/calendar/MonthCalendar2.tsx | 101 +++++++++++++++ src/calendar/Week.module.scss | 8 ++ src/calendar/Week.tsx | 14 +++ src/calendar/WeekCalendar.module.scss | 25 ++++ src/calendar/WeekCalendar.tsx | 59 ++++++++- src/widgets/Button.module.scss | 9 ++ src/widgets/ToggleSwitch.module.scss | 23 +++- src/widgets/ToggleSwitch.tsx | 17 +-- 17 files changed, 482 insertions(+), 72 deletions(-) rename lib/{month.ts => date.ts} (80%) create mode 100644 lib/persistentState.ts create mode 100644 src/calendar/MonthCalendar2.tsx create mode 100644 src/calendar/Week.module.scss create mode 100644 src/calendar/Week.tsx create mode 100644 src/calendar/WeekCalendar.module.scss diff --git a/lib/ScrollContainer.module.scss b/lib/ScrollContainer.module.scss index e69de29..37cb9ac 100644 --- a/lib/ScrollContainer.module.scss +++ b/lib/ScrollContainer.module.scss @@ -0,0 +1,24 @@ +.scrollyEl { + flex-grow: 1; + + &.horizontal { + flex-direction: row; + overflow-x: scroll; /* Add the ability to scroll the y axis */ + } + + &.vertical { + flex-direction: column; + overflow-y: scroll; /* Add the ability to scroll the y axis */ + } + + // Hide the scroll bar + + /* Hide the scrollbar for Internet Explorer, Edge and Firefox */ + -ms-overflow-style: none; /* Internet Explorer and Edge */ + scrollbar-width: none; /* Firefox */ + + /* Hide the scrollbar for Chrome, Safari and Opera */ + &::-webkit-scrollbar { + display: none; + } +} diff --git a/lib/ScrollContainer.tsx b/lib/ScrollContainer.tsx index 775dfd2..6e9547a 100644 --- a/lib/ScrollContainer.tsx +++ b/lib/ScrollContainer.tsx @@ -1,36 +1,49 @@ -import { - ForwardedRef, - forwardRef, - useCallback, - useEffect, - useState, -} from "react"; +import { Fragment, useCallback, useContext, useEffect, useState } from "react"; import styles from "./ScrollContainer.module.scss"; -import { subMonths } from "date-fns"; +import classNames from "classnames"; +import { getWeek } from "date-fns"; +import { CalendarContext } from "src/calendar/Calendar"; +import { monthNameOf, weekString } from "./date"; interface ScrollContainerProps { direction: "horizontal" | "vertical"; + className?: string; /** A list of things managed */ list: T[]; - generatePreviousObject: () => void; - generateNextObject: () => void; + renderItem: (_: T) => JSX.Element; + keyOf: (_: T) => Key; + generatePreviousObject: (_: HTMLElement) => void; + generateNextObject: (_: HTMLElement) => void; } + +interface OldScrollState { + el: HTMLElement; + offsetLeft: number; + offsetTop: number; +} + /** * Generic infinite scrolling container */ -function ScrollContainer( - { direction, list }: ScrollContainerProps, - ref: ForwardedRef -) { - const [resetScrollToEl, setResetScrollToEl] = useState< - [HTMLElement, number] | null - >(null); +function ScrollContainer({ + direction, + className, + list, + renderItem, + generateNextObject, + generatePreviousObject, + keyOf, +}: ScrollContainerProps) { + const [resetScrollToEl, setResetScrollToEl] = useState( + null + ); // Viewport state - const [viewportSize, setViewportSize] = useState<[number, number]>([0, 0]); - const [viewportWidth, viewportHeight] = viewportSize; + const { viewportWidth, viewportHeight, setViewportSize } = useContext( + CalendarContext + ); const viewportMetric = direction === "horizontal" ? viewportWidth : viewportHeight; @@ -43,6 +56,9 @@ function ScrollContainer( } }, []); + // Scroll to middle on first load + const [firstLoad, setFirstLoad] = useState(true); + // Add resize observer on the viewport useEffect(() => { const observer = new ResizeObserver((entries) => { @@ -60,43 +76,109 @@ function ScrollContainer( // Add intersection observer to tell when we scrolled too far useEffect(() => { + if (viewportWidth === 0 || viewportHeight === 0) return; if (!scrollyEl) return; - const children: HTMLElement[] = [...scrollyEl.children]; + if (firstLoad) { + const scrollLen = scrollyEl.children.length; + const middleIdx = (scrollLen + 1) / 2 - 1; + console.log("middle idx", middleIdx); + + // Identify the middle month + const el = scrollyEl.children[middleIdx]; + if (el) { + const date = new Date(el.dataset.isodate!); + console.log("first run, scrolling into view", date.getMonth()); + + el.scrollIntoView(); + setFirstLoad(false); + } + return; + } + + const children = [...scrollyEl.children] as HTMLElement[]; + const firstChild = children[0]; const lastChild = children[children.length - 1]; const observer = new IntersectionObserver((entries) => { if (viewportMetric === 0) return; - let newList = [...list]; for (const entry of entries) { if (!entry.isIntersecting) continue; - if (entry.target === firstChild) { - console.log("intersected"); - const firstChildDate = new Date(firstChild.dataset.isodate!); - const prevMonth = subMonths(firstChildDate, 1); - newList = [prevMonth, ...monthsShown]; - newList = newMonthsShown.slice(0, NUM_MONTHS_SHOWN); + // const intersectionArea = + // entry.intersectionRect.width * entry.intersectionRect.height; + // if (intersectionArea === 0) continue; - setResetScrollToEl([ - firstChild, - scrollyEl.scrollTop - firstChild.offsetTop, - ]); - } else if (entry.target === lastChild) { - const lastChildDate = new Date(lastChild.dataset.isodate!); - const nextMonth = addMonths(lastChildDate, 1); - newMonthsShown = [...monthsShown, nextMonth]; - newMonthsShown = newMonthsShown.slice(-NUM_MONTHS_SHOWN); + const date = new Date(entry.target.dataset.isodate!); + console.log("intersected with", weekString(date), entry); + + if (entry.target === firstChild) { + const newResetScrollToEl = { + el: firstChild, + offsetLeft: scrollyEl.scrollLeft - firstChild.offsetLeft, + offsetTop: scrollyEl.scrollTop - firstChild.offsetTop, + }; + setResetScrollToEl(newResetScrollToEl); + generatePreviousObject(firstChild); + } + + if (entry.target === lastChild) { + console.log("child offset left", lastChild.offsetLeft); + const newResetScrollToEl = { + el: lastChild, + offsetLeft: scrollyEl.scrollLeft - lastChild.offsetLeft, + offsetTop: scrollyEl.scrollTop - lastChild.offsetTop, + }; + setResetScrollToEl(newResetScrollToEl); + + setTimeout(() => { + generateNextObject(lastChild); + }, 1000); } } - - setMonthsShown(newMonthsShown); }); - }); - return
; + const firstDate = new Date(firstChild.dataset.isodate!); + const lastDate = new Date(lastChild.dataset.isodate!); + console.log("observing", weekString(firstDate), weekString(lastDate)); + + observer.observe(firstChild); + observer.observe(lastChild); + + return () => { + observer.unobserve(firstChild); + observer.unobserve(lastChild); + }; + }, [firstLoad, list, scrollyEl, viewportHeight]); + + useEffect(() => { + if (!resetScrollToEl) return; + if (!scrollyEl) return; + + const { el, offsetLeft, offsetTop } = resetScrollToEl; + if (direction === "horizontal") + scrollyEl.scrollLeft = el.offsetLeft + offsetLeft; + else if (direction === "vertical") + scrollyEl.scrollTop = el.offsetTop + offsetTop; + + setResetScrollToEl(null); + }, [resetScrollToEl, scrollyEl]); + + return ( + <> + {JSON.stringify([firstLoad, viewportWidth, viewportHeight])} +
+ {list.map((x) => ( + {renderItem(x)} + ))} +
+ + ); } -export default forwardRef(ScrollContainer); +export default ScrollContainer; diff --git a/lib/month.ts b/lib/date.ts similarity index 80% rename from lib/month.ts rename to lib/date.ts index 608c5ac..826538b 100644 --- a/lib/month.ts +++ b/lib/date.ts @@ -1,12 +1,15 @@ import { addDays, endOfMonth, + getWeek, isSameWeek, startOfMonth, startOfWeek, subDays, } from "date-fns"; +export const daysOfWeek = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + /** * Return week bounds in a [closed, open) fashion * The last week is not returned if it contains the next month's start date @@ -19,12 +22,17 @@ export function weekBoundsOfMonth(date: Date): [Date, Date] { const lastDayOfMonth = endOfMonth(date); const firstDayOfNextMonth = addDays(lastDayOfMonth, 1); let endWeek = startOfWeek(lastDayOfMonth); + if (isSameWeek(lastDayOfMonth, firstDayOfNextMonth)) endWeek = subDays(endWeek, 7); return [startWeek, endWeek]; } +export function weekString(date: Date): string { + return `${getWeek(date)}/${date.getFullYear()}`; +} + export function monthNameOf(date: Date): string { return date.toLocaleDateString("default", { month: "long" }); } diff --git a/lib/persistentState.ts b/lib/persistentState.ts new file mode 100644 index 0000000..567ca91 --- /dev/null +++ b/lib/persistentState.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +export function usePersistentState(key: string, defaultValue: T) { + const [value, setValue] = useState(() => { + const value = window.localStorage.getItem(key); + if (value === "undefined") return defaultValue; + return value ? JSON.parse(value) : defaultValue; + }); + + useEffect(() => { + window.localStorage.setItem(key, JSON.stringify(value)); + }, [key, value]); + + return [value, setValue]; +} diff --git a/src/calendar/Calendar.tsx b/src/calendar/Calendar.tsx index 16429e5..b913316 100644 --- a/src/calendar/Calendar.tsx +++ b/src/calendar/Calendar.tsx @@ -4,10 +4,28 @@ import ToggleSwitch from "src/widgets/ToggleSwitch"; import InputBox from "src/widgets/InputBox"; import Button from "src/widgets/Button"; import MonthCalendar from "./MonthCalendar"; +import WeekCalendar from "./WeekCalendar"; +import { usePersistentState } from "lib/persistentState"; +import MonthCalendar2 from "./MonthCalendar2"; + +export enum CalendarMode { + Week = "Week", + Month = "Month", + Year = "Year", +} + +export enum CalendarView { + Calendar = "Calendar", + Graph = "Graph", +} interface ICalendarContext { onDateClick: (_: Date) => void; setCurrentTitle: Dispatch>; + + viewportWidth: number; + viewportHeight: number; + setViewportSize: Dispatch>; } export const CalendarContext = createContext(null!); @@ -19,14 +37,29 @@ interface CalendarProps { export default function Calendar({ onDateClick }: CalendarProps) { const [currentTitle, setCurrentTitle] = useState(<>); - const scrollToToday = () => {}; - const [searchQuery, setSearchQuery] = useState(""); - const [currentMode, setCurrentMode] = useState("Month"); - const [currentView, setCurrentView] = useState("Calendar"); + const [currentMode, setCurrentMode] = usePersistentState( + "CalendarMode", + CalendarMode.Month + ); + const [currentView, setCurrentView] = usePersistentState( + "CalendarView", + CalendarView.Calendar + ); - const contextValue = { onDateClick, setCurrentTitle }; + const [viewportSize, setViewportSize] = useState<[number, number]>([0, 0]); + const [viewportWidth, viewportHeight] = viewportSize; + + const contextValue = { + onDateClick, + setCurrentTitle, + viewportWidth, + viewportHeight, + setViewportSize, + }; + + const scrollToToday = () => {}; return ( @@ -46,16 +79,18 @@ export default function Calendar({ onDateClick }: CalendarProps) {
- + options={Object.values(CalendarMode) as CalendarMode[]} value={currentMode} - setValue={setCurrentMode} + setValue={(x) => setCurrentMode(x)} + renderValue={(x) => <>{CalendarMode[x]}} /> - + options={Object.values(CalendarView) as CalendarView[]} value={currentView} setValue={setCurrentView} + renderValue={(x) => <>{CalendarView[x]}} />
@@ -67,9 +102,17 @@ export default function Calendar({ onDateClick }: CalendarProps) { ); } -function ActualCalendar({ mode }) { +interface ActualCalendarProps { + mode: CalendarMode; +} + +function ActualCalendar({ mode }: ActualCalendarProps) { switch (mode) { - default: - return ; + case CalendarMode.Month: + return ; + case CalendarMode.Week: + return ; + case CalendarMode.Year: + return ; } } diff --git a/src/calendar/Month.module.scss b/src/calendar/Month.module.scss index f1a2a64..290e21b 100644 --- a/src/calendar/Month.module.scss +++ b/src/calendar/Month.module.scss @@ -1,6 +1,3 @@ -.monthTitle { -} - .dateGrid { display: grid; diff --git a/src/calendar/Month.tsx b/src/calendar/Month.tsx index 969419b..acc5185 100644 --- a/src/calendar/Month.tsx +++ b/src/calendar/Month.tsx @@ -10,7 +10,7 @@ import { startOfWeek, } from "date-fns"; import classNames from "classnames"; -import { weekBoundsOfMonth } from "lib/month"; +import { weekBoundsOfMonth } from "lib/date"; import { journalFmt, useJournals } from "lib/queries"; interface MonthProps { diff --git a/src/calendar/MonthCalendar.module.scss b/src/calendar/MonthCalendar.module.scss index f7eeebb..a8cf066 100644 --- a/src/calendar/MonthCalendar.module.scss +++ b/src/calendar/MonthCalendar.module.scss @@ -25,3 +25,11 @@ display: none; } } + +.monthContainer { + flex-grow: 1; + + display: flex; + flex-direction: column; + overflow-y: auto; +} diff --git a/src/calendar/MonthCalendar.tsx b/src/calendar/MonthCalendar.tsx index fa4c482..1c75688 100644 --- a/src/calendar/MonthCalendar.tsx +++ b/src/calendar/MonthCalendar.tsx @@ -7,11 +7,10 @@ import { startOfMonth, subMonths, } from "date-fns"; -import { monthNameOf, weekBoundsOfMonth } from "lib/month"; +import { daysOfWeek, monthNameOf, weekBoundsOfMonth } from "lib/date"; import Month from "./Month"; import { CalendarContext } from "./Calendar"; -const daysOfWeek = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const NUM_MONTHS_SHOWN = 5; export default function MonthCalendar() { @@ -55,7 +54,7 @@ export default function MonthCalendar() { ); if (el) { console.log("first run, scrolling into view", el); - el.scrollIntoView(); + el.scrollIntoView(false); setFirstLoad(false); } diff --git a/src/calendar/MonthCalendar2.tsx b/src/calendar/MonthCalendar2.tsx new file mode 100644 index 0000000..d18f386 --- /dev/null +++ b/src/calendar/MonthCalendar2.tsx @@ -0,0 +1,101 @@ +import { useContext, useEffect, useState } from "react"; +import { CalendarContext } from "./Calendar"; +import styles from "./MonthCalendar.module.scss"; +import { + addMonths, + differenceInWeeks, + isSameMonth, + startOfMonth, + subMonths, +} from "date-fns"; +import { daysOfWeek, monthNameOf, weekBoundsOfMonth } from "lib/date"; +import Month from "./Month"; +import ScrollContainer from "lib/ScrollContainer"; + +const NUM_MONTHS_SHOWN = 5; + +export default function MonthCalendar2() { + const now = new Date(); + const { onDateClick, setCurrentTitle, viewportHeight } = useContext( + CalendarContext + ); + + const range = [-2, -1, 0, 1, 2]; + const [monthsShown, setMonthsShown] = useState( + range.map((x) => addMonths(startOfMonth(now), x)) + ); + const centerMonth = monthsShown[2]; + console.log( + monthNameOf(centerMonth), + monthsShown.map((x) => monthNameOf(x)) + ); + + useEffect(() => { + setCurrentTitle( + <> + {monthNameOf(centerMonth)} + {centerMonth.getFullYear()} + + ); + }, [centerMonth]); + + const generatePreviousObject = (el: HTMLElement) => { + const date = new Date(el.dataset.isodate!); + const prevMonth = subMonths(date, 1); + const newWeeksShown = [prevMonth, ...monthsShown]; + setMonthsShown(newWeeksShown.slice(0, NUM_MONTHS_SHOWN)); + }; + + const generateNextObject = (el: HTMLElement) => { + const date = new Date(el.dataset.isodate!); + const nextMonth = addMonths(date, 1); + const newWeeksShown = [...monthsShown, nextMonth]; + setMonthsShown(newWeeksShown.slice(-NUM_MONTHS_SHOWN)); + }; + + const keyOf = (month: Date) => month.toISOString(); + + // The calendar should always show 6 rows + const dateCellHeight = viewportHeight / 6; + + const renderItem = (month: Date) => { + // How many rows will this month take up? + const [startWeek, endWeek] = weekBoundsOfMonth(month); + const numWeeks = differenceInWeeks(endWeek, startWeek); + const dateGridHeight = dateCellHeight * numWeeks; + + const props = { + month, + dateGridHeight, + dateCellHeight, + isActive: isSameMonth(centerMonth, month), + }; + return ( + + ); + }; + + return ( + <> +
+ {daysOfWeek.map((day) => ( +
+ {day} +
+ ))} +
+ +
+ +
+ + ); +} diff --git a/src/calendar/Week.module.scss b/src/calendar/Week.module.scss new file mode 100644 index 0000000..e09e91c --- /dev/null +++ b/src/calendar/Week.module.scss @@ -0,0 +1,8 @@ +.week { + flex-grow: 1; + flex-basis: 100vw; + + display: flex; + min-width: 100vw; + border: 1px solid red; +} diff --git a/src/calendar/Week.tsx b/src/calendar/Week.tsx new file mode 100644 index 0000000..581fe7c --- /dev/null +++ b/src/calendar/Week.tsx @@ -0,0 +1,14 @@ +import { getWeek } from "date-fns"; +import styles from "./Week.module.scss"; + +export interface WeekProps { + week: Date; +} + +export default function Week({ week }: WeekProps) { + return ( +
+ Week {getWeek(week)} +
+ ); +} diff --git a/src/calendar/WeekCalendar.module.scss b/src/calendar/WeekCalendar.module.scss new file mode 100644 index 0000000..7dc6bc3 --- /dev/null +++ b/src/calendar/WeekCalendar.module.scss @@ -0,0 +1,25 @@ +.weekContainer { + flex-grow: 1; + + display: flex; + flex-direction: column; + overflow-x: auto; +} + +.weekScroll { + flex-grow: 1; + + display: flex; + flex-direction: row; + + overflow-x: scroll; + + /* Hide the scrollbar for Internet Explorer, Edge and Firefox */ + -ms-overflow-style: none; /* Internet Explorer and Edge */ + scrollbar-width: none; /* Firefox */ + + /* Hide the scrollbar for Chrome, Safari and Opera */ + &::-webkit-scrollbar { + display: none; + } +} diff --git a/src/calendar/WeekCalendar.tsx b/src/calendar/WeekCalendar.tsx index b58b28d..8ee445d 100644 --- a/src/calendar/WeekCalendar.tsx +++ b/src/calendar/WeekCalendar.tsx @@ -1,8 +1,63 @@ -import { useContext } from "react"; +import { useContext, useEffect, useState } from "react"; import { CalendarContext } from "./Calendar"; +import ScrollContainer from "lib/ScrollContainer"; +import { addWeeks, getWeek, startOfWeek, subWeeks } from "date-fns"; +import Week from "./Week"; +import styles from "./WeekCalendar.module.scss"; + +const NUM_WEEKS_SHOWN = 5; export default function WeekCalendar() { + const now = new Date(); const { onDateClick, setCurrentTitle } = useContext(CalendarContext); - return <>; + const range = [-2, -1, 0, 1, 2]; + const [weeksShown, setWeeksShown] = useState( + range.map((x) => addWeeks(startOfWeek(now), x)) + ); + const centerWeek = weeksShown[2]; + + useEffect(() => { + setCurrentTitle( + <> + {centerWeek.getFullYear()} + + {JSON.stringify(weeksShown.map((x) => getWeek(x)))} + + ); + }, [centerWeek]); + + const generatePreviousObject = (el: HTMLElement) => { + const date = new Date(el.dataset.isodate!); + const prevWeek = subWeeks(date, 1); + const newWeeksShown = [prevWeek, ...weeksShown]; + setWeeksShown(newWeeksShown.slice(0, NUM_WEEKS_SHOWN)); + }; + + const generateNextObject = (el: HTMLElement) => { + const date = new Date(el.dataset.isodate!); + const nextWeek = addWeeks(date, 1); + const newWeeksShown = [...weeksShown, nextWeek]; + setWeeksShown(newWeeksShown.slice(-NUM_WEEKS_SHOWN)); + }; + + const keyOf = (week: Date) => week.toISOString(); + + const renderItem = (week: Date) => { + return ; + }; + + return ( +
+ +
+ ); } diff --git a/src/widgets/Button.module.scss b/src/widgets/Button.module.scss index 3386b65..677be5c 100644 --- a/src/widgets/Button.module.scss +++ b/src/widgets/Button.module.scss @@ -4,4 +4,13 @@ border: none; padding: 4px 12px; background-color: lightgray; + transition: background-color 0.1s ease-out; + + &:hover { + background-color: lighten($color: lightgray, $amount: 5%); + } + + &:active { + background-color: darken($color: lightgray, $amount: 10%); + } } diff --git a/src/widgets/ToggleSwitch.module.scss b/src/widgets/ToggleSwitch.module.scss index 07e0198..1467261 100644 --- a/src/widgets/ToggleSwitch.module.scss +++ b/src/widgets/ToggleSwitch.module.scss @@ -1,6 +1,6 @@ .toggleSwitch { display: flex; - gap: 2px; + gap: 1px; border-radius: var(--border-radius); background-color: lightgray; @@ -9,13 +9,32 @@ } .option { - border-radius: var(--border-radius); border: none; background-color: inherit; padding: 4px 12px; cursor: pointer; + &:first-child { + border-top-left-radius: var(--border-radius); + border-bottom-left-radius: var(--border-radius); + } + + &:last-child { + border-top-right-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); + } + + transition: background-color 0.1s ease-out; + + &:hover { + background-color: lighten($color: lightgray, $amount: 5%); + } + + &:active { + background-color: darken($color: lightgray, $amount: 10%); + } + &.selected { background-color: gray; color: white; diff --git a/src/widgets/ToggleSwitch.tsx b/src/widgets/ToggleSwitch.tsx index 36e2e7f..fe736c9 100644 --- a/src/widgets/ToggleSwitch.tsx +++ b/src/widgets/ToggleSwitch.tsx @@ -1,19 +1,22 @@ import classNames from "classnames"; import styles from "./ToggleSwitch.module.scss"; +import { Key } from "react"; -interface ToggleSwitchProps { - options: string[]; +interface ToggleSwitchProps { + options: T[]; defaultOption?: string; - value: string; - setValue: (_: string) => any; + value: T; + renderValue: (_: T) => JSX.Element; + setValue: (_: T) => any; } -export default function ToggleSwitch({ +export default function ToggleSwitch({ options, defaultOption, value, + renderValue, setValue, -}: ToggleSwitchProps) { +}: ToggleSwitchProps) { return (
{options.map((option) => { @@ -24,7 +27,7 @@ export default function ToggleSwitch({ className={classNames(styles.option, isSelected && styles.selected)} onClick={() => setValue(option)} > - {option} + {renderValue(option)} ); })}