insane week behavior

This commit is contained in:
Michael Zhang 2023-08-24 13:37:23 -04:00
parent f375c25a48
commit 924c93271b
17 changed files with 482 additions and 72 deletions

View file

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

View file

@ -1,36 +1,49 @@
import { import { Fragment, useCallback, useContext, useEffect, useState } from "react";
ForwardedRef,
forwardRef,
useCallback,
useEffect,
useState,
} from "react";
import styles from "./ScrollContainer.module.scss"; 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<T> { interface ScrollContainerProps<T> {
direction: "horizontal" | "vertical"; direction: "horizontal" | "vertical";
className?: string;
/** A list of things managed */ /** A list of things managed */
list: T[]; list: T[];
generatePreviousObject: () => void; renderItem: (_: T) => JSX.Element;
generateNextObject: () => void; keyOf: (_: T) => Key;
generatePreviousObject: (_: HTMLElement) => void;
generateNextObject: (_: HTMLElement) => void;
} }
interface OldScrollState {
el: HTMLElement;
offsetLeft: number;
offsetTop: number;
}
/** /**
* Generic infinite scrolling container * Generic infinite scrolling container
*/ */
function ScrollContainer<T>( function ScrollContainer<T>({
{ direction, list }: ScrollContainerProps<T>, direction,
ref: ForwardedRef<HTMLDivElement> className,
) { list,
const [resetScrollToEl, setResetScrollToEl] = useState< renderItem,
[HTMLElement, number] | null generateNextObject,
>(null); generatePreviousObject,
keyOf,
}: ScrollContainerProps<T>) {
const [resetScrollToEl, setResetScrollToEl] = useState<OldScrollState | null>(
null
);
// Viewport state // Viewport state
const [viewportSize, setViewportSize] = useState<[number, number]>([0, 0]); const { viewportWidth, viewportHeight, setViewportSize } = useContext(
const [viewportWidth, viewportHeight] = viewportSize; CalendarContext
);
const viewportMetric = const viewportMetric =
direction === "horizontal" ? viewportWidth : viewportHeight; direction === "horizontal" ? viewportWidth : viewportHeight;
@ -43,6 +56,9 @@ function ScrollContainer<T>(
} }
}, []); }, []);
// Scroll to middle on first load
const [firstLoad, setFirstLoad] = useState(true);
// Add resize observer on the viewport // Add resize observer on the viewport
useEffect(() => { useEffect(() => {
const observer = new ResizeObserver((entries) => { const observer = new ResizeObserver((entries) => {
@ -60,43 +76,109 @@ function ScrollContainer<T>(
// Add intersection observer to tell when we scrolled too far // Add intersection observer to tell when we scrolled too far
useEffect(() => { useEffect(() => {
if (viewportWidth === 0 || viewportHeight === 0) return;
if (!scrollyEl) 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 firstChild = children[0];
const lastChild = children[children.length - 1]; const lastChild = children[children.length - 1];
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
if (viewportMetric === 0) return; if (viewportMetric === 0) return;
let newList = [...list];
for (const entry of entries) { for (const entry of entries) {
if (!entry.isIntersecting) continue; if (!entry.isIntersecting) continue;
if (entry.target === firstChild) { // const intersectionArea =
console.log("intersected"); // entry.intersectionRect.width * entry.intersectionRect.height;
const firstChildDate = new Date(firstChild.dataset.isodate!); // if (intersectionArea === 0) continue;
const prevMonth = subMonths(firstChildDate, 1);
newList = [prevMonth, ...monthsShown];
newList = newMonthsShown.slice(0, NUM_MONTHS_SHOWN);
setResetScrollToEl([ const date = new Date(entry.target.dataset.isodate!);
firstChild, console.log("intersected with", weekString(date), entry);
scrollyEl.scrollTop - firstChild.offsetTop,
]); if (entry.target === firstChild) {
} else if (entry.target === lastChild) { const newResetScrollToEl = {
const lastChildDate = new Date(lastChild.dataset.isodate!); el: firstChild,
const nextMonth = addMonths(lastChildDate, 1); offsetLeft: scrollyEl.scrollLeft - firstChild.offsetLeft,
newMonthsShown = [...monthsShown, nextMonth]; offsetTop: scrollyEl.scrollTop - firstChild.offsetTop,
newMonthsShown = newMonthsShown.slice(-NUM_MONTHS_SHOWN); };
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 <div className={styles.scrollyEl} ref={scrollyRef}></div>; 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])}
<div
className={classNames(styles.scrollyEl, styles[direction], className)}
ref={scrollyRef}
>
{list.map((x) => (
<Fragment key={keyOf(x)}>{renderItem(x)}</Fragment>
))}
</div>
</>
);
} }
export default forwardRef(ScrollContainer); export default ScrollContainer;

View file

@ -1,12 +1,15 @@
import { import {
addDays, addDays,
endOfMonth, endOfMonth,
getWeek,
isSameWeek, isSameWeek,
startOfMonth, startOfMonth,
startOfWeek, startOfWeek,
subDays, subDays,
} from "date-fns"; } from "date-fns";
export const daysOfWeek = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
/** /**
* Return week bounds in a [closed, open) fashion * Return week bounds in a [closed, open) fashion
* The last week is not returned if it contains the next month's start date * 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 lastDayOfMonth = endOfMonth(date);
const firstDayOfNextMonth = addDays(lastDayOfMonth, 1); const firstDayOfNextMonth = addDays(lastDayOfMonth, 1);
let endWeek = startOfWeek(lastDayOfMonth); let endWeek = startOfWeek(lastDayOfMonth);
if (isSameWeek(lastDayOfMonth, firstDayOfNextMonth)) if (isSameWeek(lastDayOfMonth, firstDayOfNextMonth))
endWeek = subDays(endWeek, 7); endWeek = subDays(endWeek, 7);
return [startWeek, endWeek]; return [startWeek, endWeek];
} }
export function weekString(date: Date): string {
return `${getWeek(date)}/${date.getFullYear()}`;
}
export function monthNameOf(date: Date): string { export function monthNameOf(date: Date): string {
return date.toLocaleDateString("default", { month: "long" }); return date.toLocaleDateString("default", { month: "long" });
} }

15
lib/persistentState.ts Normal file
View file

@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
export function usePersistentState<T>(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];
}

View file

@ -4,10 +4,28 @@ import ToggleSwitch from "src/widgets/ToggleSwitch";
import InputBox from "src/widgets/InputBox"; import InputBox from "src/widgets/InputBox";
import Button from "src/widgets/Button"; import Button from "src/widgets/Button";
import MonthCalendar from "./MonthCalendar"; 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 { interface ICalendarContext {
onDateClick: (_: Date) => void; onDateClick: (_: Date) => void;
setCurrentTitle: Dispatch<SetStateAction<JSX.Element>>; setCurrentTitle: Dispatch<SetStateAction<JSX.Element>>;
viewportWidth: number;
viewportHeight: number;
setViewportSize: Dispatch<SetStateAction<[number, number]>>;
} }
export const CalendarContext = createContext<ICalendarContext>(null!); export const CalendarContext = createContext<ICalendarContext>(null!);
@ -19,14 +37,29 @@ interface CalendarProps {
export default function Calendar({ onDateClick }: CalendarProps) { export default function Calendar({ onDateClick }: CalendarProps) {
const [currentTitle, setCurrentTitle] = useState<JSX.Element>(<></>); const [currentTitle, setCurrentTitle] = useState<JSX.Element>(<></>);
const scrollToToday = () => {};
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [currentMode, setCurrentMode] = useState("Month"); const [currentMode, setCurrentMode] = usePersistentState(
const [currentView, setCurrentView] = useState("Calendar"); "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 ( return (
<CalendarContext.Provider value={contextValue}> <CalendarContext.Provider value={contextValue}>
@ -46,16 +79,18 @@ export default function Calendar({ onDateClick }: CalendarProps) {
<div className={styles.controlButtons}> <div className={styles.controlButtons}>
<Button>Today</Button> <Button>Today</Button>
<ToggleSwitch <ToggleSwitch<CalendarMode>
options={["Week", "Month", "Year"]} options={Object.values(CalendarMode) as CalendarMode[]}
value={currentMode} value={currentMode}
setValue={setCurrentMode} setValue={(x) => setCurrentMode(x)}
renderValue={(x) => <>{CalendarMode[x]}</>}
/> />
<ToggleSwitch <ToggleSwitch<CalendarView>
options={["Calendar", "Graph"]} options={Object.values(CalendarView) as CalendarView[]}
value={currentView} value={currentView}
setValue={setCurrentView} setValue={setCurrentView}
renderValue={(x) => <>{CalendarView[x]}</>}
/> />
</div> </div>
</div> </div>
@ -67,9 +102,17 @@ export default function Calendar({ onDateClick }: CalendarProps) {
); );
} }
function ActualCalendar({ mode }) { interface ActualCalendarProps {
mode: CalendarMode;
}
function ActualCalendar({ mode }: ActualCalendarProps) {
switch (mode) { switch (mode) {
default: case CalendarMode.Month:
return <MonthCalendar />; return <MonthCalendar2 />;
case CalendarMode.Week:
return <WeekCalendar />;
case CalendarMode.Year:
return <MonthCalendar2 />;
} }
} }

View file

@ -1,6 +1,3 @@
.monthTitle {
}
.dateGrid { .dateGrid {
display: grid; display: grid;

View file

@ -10,7 +10,7 @@ import {
startOfWeek, startOfWeek,
} from "date-fns"; } from "date-fns";
import classNames from "classnames"; import classNames from "classnames";
import { weekBoundsOfMonth } from "lib/month"; import { weekBoundsOfMonth } from "lib/date";
import { journalFmt, useJournals } from "lib/queries"; import { journalFmt, useJournals } from "lib/queries";
interface MonthProps { interface MonthProps {

View file

@ -25,3 +25,11 @@
display: none; display: none;
} }
} }
.monthContainer {
flex-grow: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
}

View file

@ -7,11 +7,10 @@ import {
startOfMonth, startOfMonth,
subMonths, subMonths,
} from "date-fns"; } from "date-fns";
import { monthNameOf, weekBoundsOfMonth } from "lib/month"; import { daysOfWeek, monthNameOf, weekBoundsOfMonth } from "lib/date";
import Month from "./Month"; import Month from "./Month";
import { CalendarContext } from "./Calendar"; import { CalendarContext } from "./Calendar";
const daysOfWeek = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const NUM_MONTHS_SHOWN = 5; const NUM_MONTHS_SHOWN = 5;
export default function MonthCalendar() { export default function MonthCalendar() {
@ -55,7 +54,7 @@ export default function MonthCalendar() {
); );
if (el) { if (el) {
console.log("first run, scrolling into view", el); console.log("first run, scrolling into view", el);
el.scrollIntoView(); el.scrollIntoView(false);
setFirstLoad(false); setFirstLoad(false);
} }

View file

@ -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(
<>
<b>{monthNameOf(centerMonth)}</b>
{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 (
<Month key={month.toString()} {...props} onDateClick={onDateClick} />
);
};
return (
<>
<div className={styles.daysOfWeek}>
{daysOfWeek.map((day) => (
<div key={day} className={styles.dayOfWeek}>
{day}
</div>
))}
</div>
<div className={styles.monthContainer}>
<ScrollContainer
className={styles.weekScroll}
direction="vertical"
list={monthsShown}
renderItem={renderItem}
keyOf={keyOf}
generateNextObject={generateNextObject}
generatePreviousObject={generatePreviousObject}
/>
</div>
</>
);
}

View file

@ -0,0 +1,8 @@
.week {
flex-grow: 1;
flex-basis: 100vw;
display: flex;
min-width: 100vw;
border: 1px solid red;
}

14
src/calendar/Week.tsx Normal file
View file

@ -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 (
<div className={styles.week} data-isodate={week.toISOString()}>
Week {getWeek(week)}
</div>
);
}

View file

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

View file

@ -1,8 +1,63 @@
import { useContext } from "react"; import { useContext, useEffect, useState } from "react";
import { CalendarContext } from "./Calendar"; 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() { export default function WeekCalendar() {
const now = new Date();
const { onDateClick, setCurrentTitle } = useContext(CalendarContext); 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()}
<b>{JSON.stringify(weeksShown.map((x) => getWeek(x)))}</b>
</>
);
}, [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 <Week key={week.toISOString()} week={week} />;
};
return (
<div className={styles.weekContainer}>
<ScrollContainer
className={styles.weekScroll}
direction="horizontal"
list={weeksShown}
renderItem={renderItem}
keyOf={keyOf}
generateNextObject={generateNextObject}
generatePreviousObject={generatePreviousObject}
/>
</div>
);
} }

View file

@ -4,4 +4,13 @@
border: none; border: none;
padding: 4px 12px; padding: 4px 12px;
background-color: lightgray; 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%);
}
} }

View file

@ -1,6 +1,6 @@
.toggleSwitch { .toggleSwitch {
display: flex; display: flex;
gap: 2px; gap: 1px;
border-radius: var(--border-radius); border-radius: var(--border-radius);
background-color: lightgray; background-color: lightgray;
@ -9,13 +9,32 @@
} }
.option { .option {
border-radius: var(--border-radius);
border: none; border: none;
background-color: inherit; background-color: inherit;
padding: 4px 12px; padding: 4px 12px;
cursor: pointer; 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 { &.selected {
background-color: gray; background-color: gray;
color: white; color: white;

View file

@ -1,19 +1,22 @@
import classNames from "classnames"; import classNames from "classnames";
import styles from "./ToggleSwitch.module.scss"; import styles from "./ToggleSwitch.module.scss";
import { Key } from "react";
interface ToggleSwitchProps { interface ToggleSwitchProps<T> {
options: string[]; options: T[];
defaultOption?: string; defaultOption?: string;
value: string; value: T;
setValue: (_: string) => any; renderValue: (_: T) => JSX.Element;
setValue: (_: T) => any;
} }
export default function ToggleSwitch({ export default function ToggleSwitch<T extends Key>({
options, options,
defaultOption, defaultOption,
value, value,
renderValue,
setValue, setValue,
}: ToggleSwitchProps) { }: ToggleSwitchProps<T>) {
return ( return (
<div className={styles.toggleSwitch}> <div className={styles.toggleSwitch}>
{options.map((option) => { {options.map((option) => {
@ -24,7 +27,7 @@ export default function ToggleSwitch({
className={classNames(styles.option, isSelected && styles.selected)} className={classNames(styles.option, isSelected && styles.selected)}
onClick={() => setValue(option)} onClick={() => setValue(option)}
> >
{option} {renderValue(option)}
</button> </button>
); );
})} })}