Frontend Development 18 min read

Building a Work Calendar Component with Date APIs and Box Selection in React

This article walks through creating a customizable work‑calendar component in React, covering lunar‑type date APIs, essential Date methods, data structures, algorithms for generating month and year weeks, and implementing a draggable box‑selection feature for batch date type editing.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Building a Work Calendar Component with Date APIs and Box Selection in React

Background: The author needed a work‑calendar component and, after not finding a suitable one, decided to implement it from scratch. A demo is available at https://dbfu.github.io/work-calendar/ .

Library Introduction: The lunar-typescript library provides comprehensive lunar, solar, Buddhist, and Taoist calendar utilities, including conversions, zodiac, festivals, solar terms, and more, without external dependencies.

Date API Review: Demonstrates how to construct dates with new Date(year, month, day) (month is zero‑based), retrieve the weekday via getDay() (0 = Sunday), obtain the last day of the previous month by passing 0 as the day, calculate the number of days in a month, perform date arithmetic, and compare dates using getTime() to avoid reference comparison pitfalls.

Data Structures: Defines TypeScript interfaces for the calendar data. /** * 日期信息 */ export interface DateInfo { /** 年 */ year: number; /** 月 */ month: number; /** 日 */ day: number; /** 日期对象 */ date: Date; /** 农历日 */ cnDay: string; /** 农历月 */ cnMonth: string; /** 农历年 */ cnYear: string; /** 节气 */ jieQi: string; /** 是否当前月 */ isCurMonth?: boolean; /** 星期几 */ week: number; /** 节日名称 */ festivalName: string; } /** * 月份的所有周 */ export interface MonthWeek { /** 月 */ month: number; /** 按周分组的日期,7天一组 */ weeks: DateInfo[][]; }

Algorithm Implementations:

Getting date information using the lunar library.

export const getDateInfo = (date: Date, isCurMonth?: boolean): DateInfo => {
  const lunar = Lunar.fromDate(date);
  const cnDay = lunar.getDayInChinese();
  const cnMonth = lunar.getMonthInChinese();
  const cnYear = lunar.getYearInChinese();
  const festivals = lunar.getFestivals();
  const jieQi = lunar.getJieQi();
  const year = date.getFullYear();
  const month = date.getMonth();
  const day = date.getDate();
  return {
    year,
    month,
    day,
    date,
    cnDay,
    cnMonth,
    cnYear,
    jieQi,
    isCurMonth,
    week: date.getDay(),
    festivalName: festivals?.[0] || festivalMap[`${month + 1}-${day}`],
  };
};

Generating weeks for a month, handling leading and trailing empty cells by filling with adjacent month dates.

const getMonthWeeks = (year: number, month: number, weekStartDay: number) => {
  const start = new Date(year, month, 1);
  const day = getDay(start, weekStartDay);
  const days: DateInfo[] = [];
  for (let i = 0; i < day; i++) {
    days.push(getDateInfo(new Date(year, month, -day + i + 1)));
  }
  const monthDay = new Date(year, month + 1, 0).getDate();
  for (let i = 1; i <= monthDay; i++) {
    days.push(getDateInfo(new Date(year, month, i), true));
  }
  const endDate = new Date(year, month + 1, 0);
  const endDay = getDay(endDate, weekStartDay);
  for (let i = endDay; i <= 5; i++) {
    days.push(getDateInfo(new Date(year, month + 1, i - endDay + 1)));
  }
  const weeks: DateInfo[][] = [];
  for (let i = 0; i < days.length; i += 1) {
    if (i % 7 === 0) {
      weeks.push(days.slice(i, i + 7));
    }
  }
  while (weeks.length < 6) {
    const endDate = weeks[weeks.length - 1][6];
    weeks.push(
      Array.from({ length: 7 }).map((_, i) => {
        const newDate = new Date(endDate.date);
        newDate.setDate(newDate.getDate() + i + 1);
        return getDateInfo(newDate);
      })
    );
  }
  return weeks;
};

Utility to get the weekday respecting a custom start day.

function getDay(date: Date, weekStartDay: number) {
  const day = date.getDay();
  if (weekStartDay === 1) {
    return day === 0 ? 6 : day - 1;
  }
  return day;
}

Generating all weeks for a year, grouped by month.

export const getYearWeeks = (year: number, weekStartDay = 0): MonthWeek[] => {
  const weeks = [];
  for (let i = 0; i <= 11; i++) {
    weeks.push({ month: i, weeks: getMonthWeeks(year, i, weekStartDay) });
  }
  return weeks;
};

Page Layout: Uses CSS Grid to display four calendar columns per row and a table layout for the dates, applying different background colors for workdays, rest days, and holidays.

Maintaining Date Types: To allow users to modify date classifications, a clickable cell opens a modal, and a rectangle‑selection feature enables batch updates. The rectangle intersection logic is as follows.

interface Rect {
  x: number;
  y: number;
  width: number;
  height: number;
}

export function isRectangleIntersect(rect1: Rect, rect2: Rect) {
  const x1 = rect1.x;
  const y1 = rect1.y;
  const x2 = rect1.x + rect1.width;
  const y2 = rect1.y + rect1.height;
  const x3 = rect2.x;
  const y3 = rect2.y;
  const x4 = rect2.x + rect2.width;
  const y4 = rect2.y + rect2.height;
  if (x1 < x4 && x2 > x3 && y1 < y4 && y2 > y3) {
    return true;
  } else {
    return false;
  }
}

BoxSelect Component (React) that renders a fixed, semi‑transparent rectangle while the mouse is pressed and reports intersecting elements.

import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { isRectangleIntersect } from './utils';

function BoxSelect({ selectors, sourceClassName, onSelectChange, onSelectEnd, style }) {
  const [position, setPosition] = useState({ top: 0, left: 0, width: 0, height: 0 });
  const isPress = useRef(false);
  const startPos = useRef();

  useEffect(() => {
    function scroll() { if (!isPress.current) return; setPosition(p => ({ ...p })); }
    function sourceMouseDown(e) { isPress.current = true; startPos.current = { top: e.clientY, left: e.clientX }; setPosition({ top: e.clientY, left: e.clientX, width: 1, height: 1 }); window.getSelection()?.removeAllRanges(); }
    function mousemove(e) { if (!isPress.current) return; let left = startPos.current.left; let top = startPos.current.top; const width = Math.abs(e.clientX - startPos.current.left); const height = Math.abs(e.clientY - startPos.current.top); if (e.clientX < startPos.current.left) left = e.clientX; if (e.clientY < startPos.current.top) top = e.clientY; setPosition({ top, left, width, height }); }
    function mouseup() { if (!isPress.current) return; startPos.current = null; isPress.current = false; setPosition(p => ({ ...p })); onSelectEnd && onSelectEnd(); }
    const sourceDom = document.querySelector(`.${sourceClassName}`);
    sourceDom && sourceDom.addEventListener('mousedown', sourceMouseDown);
    document.addEventListener('scroll', scroll);
    document.addEventListener('mousemove', mousemove);
    document.addEventListener('mouseup', mouseup);
    return () => {
      document.removeEventListener('scroll', scroll);
      document.removeEventListener('mousemove', mousemove);
      document.removeEventListener('mouseup', mouseup);
      sourceDom && sourceDom.removeEventListener('mousedown', sourceMouseDown);
    };
  }, []);

  useEffect(() => {
    const selectDoms = [];
    const boxes = document.querySelectorAll(selectors);
    boxes.forEach(box => {
      if (isRectangleIntersect({ x: position.left, y: position.top, width: position.width, height: position.height }, box.getBoundingClientRect())) {
        selectDoms.push(box);
      }
    });
    onSelectChange && onSelectChange(selectDoms);
  }, [position]);

  return createPortal(
    isPress.current && (
),
    document.body
  );
}

export default BoxSelect;

Usage in the main App component: the BoxSelect component tracks selected dates, opens an Ant Design modal to set the date type (workday, rest day, holiday), and updates the calendar state accordingly.

import { Modal, Radio } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react';
import BoxSelect from './box-select';
import WorkCalendar from './work-calendar';

function App() {
  const [selectDates, setSelectDates] = useState
([]);
  const [open, setOpen] = useState(false);
  const [dateType, setDateType] = useState
();
  const [dates, setDates] = useState
({});
  const selectDatesRef = useRef
([]);
  const workDays = useMemo(() => Object.keys(dates).filter(d => dates[d] === 1), [dates]);
  const restDays = useMemo(() => Object.keys(dates).filter(d => dates[d] === 2), [dates]);
  const holidayDays = useMemo(() => Object.keys(dates).filter(d => dates[d] === 3), [dates]);
  useEffect(() => { selectDatesRef.current = selectDates; }, [selectDates]);
  return (
setSelectDates(doms.map(d => d.getAttribute('data-date') as string))}
        onSelectEnd={() => { if (selectDatesRef.current.length) setOpen(true); }}
      />
{ setOpen(false); setSelectDates([]); setDateType(null); }}
        onOk={() => {
          setOpen(false);
          selectDatesRef.current.forEach(date => {
            setDates(prev => ({ ...prev, [date]: dateType }));
          });
          setSelectDates([]);
          setDateType(null);
        }}
      >
setDateType(e.target.value)}
        />
);
}

export default App;

Modifications to the WorkCalendar component add a date class and a data-date attribute to each td , enabling the selection logic to identify dates.

Conclusion: The author notes that adding throttling to mousemove was unnecessary for performance, and revisits several useful but less‑common Date methods while reflecting on the implementation.

Demo and source code are hosted at https://dbfu.github.io/work-calendar/ and https://github.com/dbfu/work-calendar .

frontendTypeScriptReactcalendarBox SelectionDate API
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.