Skip to content

Commit

Permalink
chore: refactor Calendar and time utils, create hooks dir, add tsdoc
Browse files Browse the repository at this point in the history
- update deps
- add tsdoc to eslint
- refactor calendar to better use luxon library
- rename and refactor time utils to make it more clear
- create hooks dir and move useInterval.ts there
  • Loading branch information
jeremy-code committed Jan 28, 2024
1 parent 82655dc commit 8ae51a3
Show file tree
Hide file tree
Showing 18 changed files with 304 additions and 157 deletions.
11 changes: 9 additions & 2 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@
"plugin:drizzle/recommended"
],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "drizzle"],
"plugins": ["@typescript-eslint", "tsdoc", "drizzle"],
"rules": {
"tsdoc/syntax": "warn"
},
"overrides": [
{
"files": ["**/*.js", "**/*.jsx", "**/*.cjs", "**/*.mjs"],
"extends": ["plugin:@typescript-eslint/disable-type-checked"]
"extends": ["plugin:@typescript-eslint/disable-type-checked"],
"rules": {
"@typescript-eslint/no-var-requires": "off",
"tsdoc/syntax": "off"
}
}
],
"root": true
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@
},
"dependencies": {
"@ark-ui/react": "^1.3.0",
"@auth/core": "^0.23.0",
"@auth/drizzle-adapter": "^0.3.17",
"@auth/core": "^0.24.0",
"@auth/drizzle-adapter": "^0.4.0",
"@next/bundle-analyzer": "canary",
"@react-aria/datepicker": "^3.9.1",
"@react-aria/i18n": "^3.10.0",
Expand Down Expand Up @@ -71,7 +71,7 @@
"@pandacss/types": "^0.28.0",
"@park-ui/panda-preset": "^0.31.0",
"@types/luxon": "^3.4.2",
"@types/node": "^20.11.7",
"@types/node": "^20.11.8",
"@types/pg": "^8.11.0",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
Expand All @@ -83,6 +83,7 @@
"eslint": "^8.56.0",
"eslint-config-next": "14.1.0",
"eslint-plugin-drizzle": "^0.2.3",
"eslint-plugin-tsdoc": "^0.2.17",
"husky": "^9.0.6",
"lint-staged": "^15.2.0",
"postcss": "^8.4.33",
Expand Down
2 changes: 1 addition & 1 deletion prettier.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ module.exports = {
"^(next/(.*)$)|^(next$)",
"<THIRD_PARTY_MODULES>",
"",
"^@/(components|lib|utils|interfaces)/(.*)$",
"^@/(components|hooks|interfaces|lib|utils)/(.*)$",
"^@/(.*)$",
"^[./]",
],
Expand Down
80 changes: 24 additions & 56 deletions src/components/calendar/Calendar.tsx
Original file line number Diff line number Diff line change
@@ -1,90 +1,58 @@
import React, { type CSSProperties } from "react";
import { Info } from "luxon";

import { CalendarEvent, CurrentTime } from "@/components/calendar";
import { Text } from "@/components/ui";
import { CalendarEvent, CalendarHeader, CalendarTimes, CurrentTime } from "@/components/calendar";
import { Grid, GridItem, type GridProps } from "@/lib/styled/jsx";
import { getTimeRange, getWeekdaysIndex, sortWeekdays, TIME_NARROW } from "@/utils/time";
import { hoursFromIntervals, reorderWeekdaysByStart, WEEKDAY_NUMBERS } from "@/utils/time";
import type { Event } from "@/interfaces";

type CalendarProps = {
events: Event[];
} & GridProps;

export const Calendar = ({ events, ...rest }: CalendarProps) => {
const timeRange = getTimeRange(events.map((event) => event.interval));
const [start, end] = [timeRange[0], timeRange.at(-1)];

// end is undefined, or start or end times are invalid
if (!end || !start.isValid || !end.isValid) return null;
const timeRange = hoursFromIntervals(events.map((event) => event.interval));

return (
<Grid grid="'. header' 'time calendar' / max-content auto" gap="0" {...rest}>
<Grid gridArea="header" columns={7} gap="1px">
{sortWeekdays(Info.weekdays("short")).map((day) => (
<GridItem key={day} textAlign="center">
{day}
</GridItem>
))}
</Grid>

<Grid gridArea="time" gap="1px">
{timeRange.map((time) => (
<Grid
key={time.toString()}
pos="relative"
placeContent="end"
_before={{
content: "''",
w: "3",
pos: "absolute",
// inset bottom -1px to account for border, right 0 to right-align
inset: "auto 0 -1px auto",
borderBottom: "muted",
}}
>
<Text translate="auto" y="50%" mr="4" fontWeight="normal" fontSize="sm">
{time.toFormat(TIME_NARROW)}
</Text>
</Grid>
))}
</Grid>
<CalendarHeader gridArea="header" />
<CalendarTimes gridArea="time" timeRange={timeRange} />

<Grid
gridArea="calendar"
gap="1px"
bg="border.muted"
// using outline here since border would misalign the grid
outline="muted"
grid="repeat(var(--rows), token(sizes.12)) / repeat(7, 1fr)"
grid="[main-start] repeat(var(--rows), token(sizes.12)) [main-end] / [main-start] repeat(7, 1fr) [main-end]"
style={{ "--rows": timeRange.length } as CSSProperties}
>
{/* Current time indicator */}
<CurrentTime startHour={start.hour} endHour={end.hour} />
{/* Indicates current time */}
<CurrentTime startHour={timeRange[0].hour} endHour={timeRange.at(-1)?.hour ?? 1} />

{/* Events */}
<Grid grid="subgrid/subgrid" gridArea="1/1/-1/-1" gap="1px" zIndex="1">
{events.map((event) =>
event.weekdays.map((day) => (
<CalendarEvent
key={`${day}-${event.interval}`}
weekday={day}
startHour={start.hour}
{...event}
/>
))
)}
</Grid>
{events.map((event) =>
event.weekdays.map((day) => (
<CalendarEvent
key={`${day}-${event.interval}`}
weekday={day}
timeRange={timeRange}
{...event}
/>
))
)}

{/* Decorative elements, creates borders between cells */}
{/* Using subgrid here to allow grid to auto place elements while overlapping */}
<Grid grid="subgrid/subgrid" gridArea="1/1/-1/-1" gap="1px" gridAutoFlow="column">
{sortWeekdays(getWeekdaysIndex()).map((day) =>
{/* Using subgrid here to allow subgrid to auto place elements while above elements
/* can overlap, which is impossible on an implicit grid */}
<Grid grid="subgrid/auto-flow" gridArea="main" gap="1px">
{reorderWeekdaysByStart(WEEKDAY_NUMBERS).map((day) =>
timeRange.map((time) => (
<GridItem
key={`${day}-${time}`}
aria-hidden
// weekends have muted background
bgColor={Info.getWeekendWeekdays().includes(day) ? "bg.muted" : "bg.default"}
bg={Info.getWeekendWeekdays().includes(day) ? "bg.muted" : "bg.default"}
/>
))
)}
Expand Down
11 changes: 6 additions & 5 deletions src/components/calendar/CalendarEvent.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
import { Info, type WeekdayNumbers } from "luxon";
import type { DateTime, WeekdayNumbers } from "luxon";

import { Text } from "@/components/ui";
import type { Event } from "@/interfaces/time";
import { Flex, GridItem } from "@/lib/styled/jsx";
import { hexToRgb, rgbToYIQ } from "@/utils/colors";
import { getPercentage } from "@/utils/common";
import { getWeekdayOffset } from "@/utils/time";

type CalendarEventProps = {
startHour: number;
timeRange: DateTime[];
weekday: WeekdayNumbers;
} & Omit<Event, "weekdays">;

export const CalendarEvent = ({
name,
interval,
weekday,
startHour,
timeRange,
color,
}: CalendarEventProps) => {
const duration = Math.ceil(interval.length("hour"));
if (!interval.start?.isValid) return;
const startOfWeek = Info.getStartOfWeek();
const startHour = timeRange[0].hour;

// offset by the start of the week
const col = ((weekday - startOfWeek + 7) % 7) + 1;
const col = getWeekdayOffset(weekday);
const row = interval.start.hour - startHour + 2;
const offset = interval.start.minute / 60;

Expand Down
16 changes: 16 additions & 0 deletions src/components/calendar/CalendarHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Info } from "luxon";

import { Grid, GridItem, type GridProps } from "@/lib/styled/jsx";
import { reorderWeekdaysByStart } from "@/utils/time";

export const CalendarHeader = (props: GridProps) => {
return (
<Grid columns={7} gap="1px" {...props}>
{reorderWeekdaysByStart(Info.weekdays("short")).map((day) => (
<GridItem key={day} textAlign="center">
{day}
</GridItem>
))}
</Grid>
);
};
37 changes: 37 additions & 0 deletions src/components/calendar/CalendarTimes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { DateTimeMaybeValid } from "luxon";

import { Text } from "@/components/ui";
import { Grid, GridProps } from "@/lib/styled/jsx";
import { TIME_NARROW } from "@/utils/time";

type CalendarTimesProps = {
timeRange: DateTimeMaybeValid[];
} & GridProps;

export const CalendarTimes = ({ timeRange, ...rest }: CalendarTimesProps) => {
return (
<Grid gap="1px" {...rest}>
{timeRange.map((time) => (
<Grid
key={`${time}`}
pos="relative"
placeContent="end"
// padding-right to account for pseudo-element
pr={4}
_before={{
content: "''",
pos: "absolute",
w: "3",
// inset bottom -1px to account for border, right 0 to right-align
inset: "auto 0 -1px auto",
borderBottom: "muted",
}}
>
<Text translate="auto" y="50%" fontWeight="normal" fontSize="sm">
{time.toFormat(TIME_NARROW)}
</Text>
</Grid>
))}
</Grid>
);
};
46 changes: 20 additions & 26 deletions src/components/calendar/CurrentTime.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,40 @@
"use client";

import React, { useEffect, useState, type CSSProperties } from "react";
import { DateTime, type HourNumbers } from "luxon";
import React, { useState } from "react";
import { useLocale } from "@react-aria/i18n";
import { DateTime, Info, type HourNumbers } from "luxon";

import { Box, GridItem } from "@/lib/styled/jsx";
import { getPercentage } from "@/utils/common";
import { getWeekdayOffset } from "@/utils/time";
import { useInterval } from "@/hooks";

type CurrentTimeProps = {
startHour: HourNumbers;
endHour: HourNumbers;
};

export const CurrentTime = ({ startHour, endHour }: CurrentTimeProps) => {
const [curr, setCurr] = useState(DateTime.now());
const { locale } = useLocale();
const [curr, setCurr] = useState(DateTime.now().setLocale(locale));

useEffect(() => {
const interval = setInterval(() => {
setCurr(DateTime.now());
// update every minute
}, 60 * 1000);
// update every minute
useInterval(() => setCurr(DateTime.now().setLocale(locale)), 60 * 1000);

return () => clearInterval(interval);
}, []);
if (!Info.features().localeWeek) return null;

// time indicator would not be visible since it is outside of the calendar
if (startHour > curr.hour || endHour < curr.hour) return null;

const offset = curr.minute / 60;

// z-index so above any events
// visibility hidden so user can interact with events
return (
<GridItem
// visibility is hidden to prevent empty space overlapping with events
// z-index is set to 1 to prevent the indicator from being covered by events
visibility="hidden"
zIndex="2"
gridArea="var(--row) / var(--col)"
style={
{
"--row": curr.hour - startHour + 2,
"--col": getWeekdayOffset(curr.weekday),
} as CSSProperties
}
zIndex="1"
style={{
gridRow: curr.hour - startHour + 2,
gridColumn: curr.localWeekday,
}}
>
<Box
pos="relative"
Expand All @@ -50,19 +43,20 @@ export const CurrentTime = ({ startHour, endHour }: CurrentTimeProps) => {
content: '""',
visibility: "visible",
pos: "absolute",
bg: "red",
// left, right, bottom are set to 0, or full width, bottom aligned
inset: "auto 0 0",
bg: "red",
},
}}
_before={{
h: "3",
w: "3",
rounded: "full",
// move left and down to be centered at left bottom corner
translate: "-50% 50%",
}}
_after={{ h: "1px" }}
will-change="top"
style={{ top: getPercentage(offset) }}
style={{ top: getPercentage(curr.minute / 60) }}
/>
</GridItem>
);
Expand Down
2 changes: 2 additions & 0 deletions src/components/calendar/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from "./Calendar";
export * from "./CalendarEvent";
export * from "./CurrentTime";
export * from "./CalendarHeader";
export * from "./CalendarTimes";
6 changes: 3 additions & 3 deletions src/components/form/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ type FormProps<T> = {
* A wrapper around the native form element that handles form submission and error handling. For
* all errors, a toast notification is shown.
*
* @param children The form elements
* @param action The form action (with state) that is used in `useFormState()`
* @param onSubmitSuccess A callback that is called when the form is successfully submitted
* @param children - The form elements
* @param action - The form action (with state) that is used in `useFormState()`
* @param onSubmitSuccess - A callback that is called when the form is successfully submitted
* (i.e. state.status === "ok")
*/
export const Form = <T,>({ children, action, onSubmitSuccess, ...rest }: FormProps<T>) => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/form/FormButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ type FormButtonProps = {
* Button wrapped in a form, useful for server actions that don't require input
* but still need to be submitted as a form.
*
* @param action The action to perform when the button is clicked.
* @param action - The action to perform when the button is clicked.
* @returns A button wrapped in a form.
*/
export const FormButton = ({ action, children, ...rest }: FormButtonProps) => {
Expand Down
8 changes: 4 additions & 4 deletions src/components/form/Prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ type PromptProps<T> = {
* A wrapper around the Dialog component that handles form submission and error handling. For all
* errors, a toast notification is shown.
*
* @param title The title of the dialog
* @param description The description of the dialog
* @param children The form elements
* @param action The form action (with state)
* @param title - The title of the dialog
* @param description - The description of the dialog
* @param children - The form elements
* @param action - The form action (with state)
*/
export const Prompt = <T,>({ title, description, children, action, ...props }: PromptProps<T>) => {
const [isOpen, setIsOpen] = useState(false);
Expand Down
Loading

1 comment on commit 8ae51a3

@vercel
Copy link

@vercel vercel bot commented on 8ae51a3 Jan 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.