1- import interactionPlugin from "@fullcalendar/interaction" ;
2- import FullCalendar from "@fullcalendar/react" ;
3- import timeGridPlugin from "@fullcalendar/timegrid" ;
4- import dayjs from "dayjs" ;
5- import "dayjs/locale/ja" ;
61import type {
72 DateSelectArg ,
83 DateSpanApi ,
@@ -12,23 +7,26 @@ import type {
127 EventMountArg ,
138 SlotLabelContentArg ,
149} from "@fullcalendar/core/index.js" ;
10+ import interactionPlugin from "@fullcalendar/interaction" ;
11+ import momentTimezonePlugin from "@fullcalendar/moment-timezone" ;
12+ import FullCalendar from "@fullcalendar/react" ;
13+ import timeGridPlugin from "@fullcalendar/timegrid" ;
1514import type React from "react" ;
1615import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
1716import { Tooltip } from "react-tooltip" ;
1817import useCalendarScrollBlock from "../hooks/useCalendarScrollBlock" ;
1918import { EditingMatrix , ViewingMatrix } from "../lib/CalendarMatrix" ;
19+ import dayjs , { type Dayjs } from "../lib/dayjs" ;
2020import type { EditingSlot } from "../pages/eventId/Submission" ;
2121
22- dayjs . locale ( "ja" ) ;
23-
2422type AllowedRange = {
25- startTime : Date ;
26- endTime : Date ;
23+ startTime : Dayjs ;
24+ endTime : Dayjs ;
2725} ;
2826
2927type ViewingSlot = {
30- from : Date ;
31- to : Date ;
28+ from : Dayjs ;
29+ to : Dayjs ;
3230 guestId : string ;
3331 optionId : string ;
3432} ;
@@ -55,8 +53,8 @@ type CalendarEvent = Pick<EventInput, "id" | "className" | "start" | "end" | "te
5553} ;
5654
5755type Props = {
58- startDate : Date ;
59- endDate : Date ;
56+ startDate : Dayjs ;
57+ endDate : Dayjs ;
6058 allowedRanges : AllowedRange [ ] ;
6159 editingSlots : EditingSlot [ ] ;
6260 viewingSlots : ViewingSlot [ ] ;
@@ -103,15 +101,15 @@ export const Calendar = ({
103101 editMode,
104102 onChangeEditingSlots,
105103} : Props ) => {
106- const countDays = dayjs ( endDate ) . startOf ( "day" ) . diff ( dayjs ( startDate ) . startOf ( "day" ) , "day" ) + 1 ;
104+ const countDays = endDate . startOf ( "day" ) . diff ( startDate . startOf ( "day" ) , "day" ) + 1 ;
107105 // TODO: +1 は不要かも
108106 const editingMatrixRef = useRef < EditingMatrix > ( new EditingMatrix ( countDays + 1 , startDate ) ) ;
109107 const viewingMatrixRef = useRef < ViewingMatrix > ( new ViewingMatrix ( countDays + 1 , startDate ) ) ;
110108
111109 // TODO: 現在は最初の選択範囲のみ。FullCalendar の制約により、複数の allowedRanges には対応できないため、のちに selectAllow などで独自実装が必要
112110 const tmpAllowedRange = allowedRanges [ 0 ] ?? {
113- startTime : dayjs ( new Date ( ) ) . set ( "hour" , 0 ) . set ( "minute" , 0 ) . toDate ( ) ,
114- endTime : dayjs ( new Date ( ) ) . set ( "hour" , 23 ) . set ( "minute" , 59 ) . toDate ( ) ,
111+ startTime : dayjs . utc ( ) . tz ( ) . set ( "hour" , 0 ) . set ( "minute" , 0 ) . toDate ( ) ,
112+ endTime : dayjs . utc ( ) . tz ( ) . set ( "hour" , 23 ) . set ( "minute" , 59 ) . toDate ( ) ,
115113 } ;
116114
117115 const calendarRef = useRef < FullCalendar | null > ( null ) ;
@@ -126,14 +124,14 @@ export const Calendar = ({
126124 // editingSlots → editingMatrix
127125 editingMatrixRef . current . clear ( ) ;
128126 editingSlots . forEach ( ( slot ) => {
129- const { from, to } = getVertexes ( slot . from , slot . to ) ;
127+ const { from, to } = normalizeVertexes ( slot . from , slot . to ) ;
130128 editingMatrixRef . current . setRange ( from , to , slot . participationOptionId ) ;
131129 } ) ;
132130
133131 viewingMatrixRef . current . clear ( ) ;
134132
135133 viewingSlots . forEach ( ( slot ) => {
136- const { from, to } = getVertexes ( slot . from , slot . to ) ;
134+ const { from, to } = normalizeVertexes ( slot . from , slot . to ) ;
137135 viewingMatrixRef . current . setGuestRange ( from , to , slot . guestId , slot . optionId ) ;
138136 } ) ;
139137
@@ -147,8 +145,8 @@ export const Calendar = ({
147145 return {
148146 id : `${ EDITING_EVENT } -${ index } ` ,
149147 className : EDITING_EVENT ,
150- start : slot . from ,
151- end : slot . to ,
148+ start : slot . from . format ( ) ,
149+ end : slot . to . format ( ) ,
152150 textColor : "white" ,
153151 backgroundColor,
154152 borderColor : baseColor ,
@@ -218,8 +216,8 @@ export const Calendar = ({
218216 viewingEvents . push ( {
219217 id : `${ VIEWING_EVENT } -${ index } ` ,
220218 className : `${ VIEWING_EVENT } ${ VIEWING_EVENT } -${ index } ` ,
221- start : slot . from ,
222- end : slot . to ,
219+ start : slot . from . format ( ) ,
220+ end : slot . to . format ( ) ,
223221 color : defaultColor ,
224222 display : "background" as const ,
225223 extendedProps : {
@@ -316,18 +314,18 @@ export const Calendar = ({
316314 dayHeaderContent : ( args : DayHeaderContentArg ) => {
317315 return (
318316 < div className = "font-normal text-gray-600" >
319- < div > { dayjs ( args . date ) . format ( "M/D" ) } </ div >
320- < div > { dayjs ( args . date ) . format ( "(ddd)" ) } </ div >
317+ < div > { dayjs . utc ( args . date ) . tz ( ) . format ( "M/D" ) } </ div >
318+ < div > { dayjs . utc ( args . date ) . tz ( ) . format ( "(ddd)" ) } </ div >
321319 </ div >
322320 ) ;
323321 } ,
324322 slotLabelContent : ( args : SlotLabelContentArg ) => {
325- return < div className = "text-gray-600" > { dayjs ( args . date ) . format ( "HH:mm" ) } </ div > ;
323+ return < div className = "text-gray-600" > { dayjs . utc ( args . date ) . tz ( ) . format ( "HH:mm" ) } </ div > ;
326324 } ,
327325 slotLabelInterval : "00:30:00" ,
328326 validRange : {
329- start : startDate ,
330- end : endDate ,
327+ start : startDate . format ( ) ,
328+ end : endDate . format ( ) ,
331329 } ,
332330 expandRows : true ,
333331 } ,
@@ -356,7 +354,7 @@ export const Calendar = ({
356354 ( info : DateSelectArg ) => {
357355 if ( ! editMode ) return ;
358356
359- const { from, to } = getVertexes ( info . start , info . end ) ;
357+ const { from, to } = normalizeVertexes ( dayjs . utc ( info . start ) . tz ( ) , dayjs . utc ( info . end ) . tz ( ) ) ;
360358
361359 if ( isSelectionDeleting . current === null ) return ;
362360 const isDeletion = isSelectionDeleting . current ;
@@ -456,7 +454,7 @@ export const Calendar = ({
456454 }
457455 if ( info . event . classNames . includes ( EDITING_EVENT ) ) {
458456 return (
459- < div className = "h-full w-full overflow-hidden text-gray-600" > { `${ dayjs ( info . event . start ) . format ( "HH:mm" ) } - ${ dayjs ( info . event . end ) . format ( "HH:mm" ) } ` } </ div >
457+ < div className = "h-full w-full overflow-hidden text-gray-600" > { `${ dayjs . utc ( info . event . start ) . tz ( ) . format ( "HH:mm" ) } - ${ dayjs . utc ( info . event . end ) . tz ( ) . format ( "HH:mm" ) } ` } </ div >
460458 ) ;
461459 }
462460 } , [ ] ) ;
@@ -472,14 +470,14 @@ export const Calendar = ({
472470 < div className = "my-2 flex-1" id = "ih-cal-wrapper" >
473471 < FullCalendar
474472 ref = { calendarRef }
475- plugins = { [ timeGridPlugin , interactionPlugin ] }
473+ plugins = { [ timeGridPlugin , interactionPlugin , momentTimezonePlugin ] }
476474 height = { "100%" }
477475 longPressDelay = { LONG_PRESS_DELAY }
478476 slotDuration = { "00:15:00" }
479477 allDaySlot = { false }
480- initialDate = { startDate }
481- slotMinTime = { dayjs ( tmpAllowedRange . startTime ) . format ( "HH:mm:ss" ) }
482- slotMaxTime = { dayjs ( tmpAllowedRange . endTime ) . format ( "HH:mm:ss" ) }
478+ initialDate = { startDate . startOf ( "day" ) . toDate ( ) }
479+ slotMinTime = { tmpAllowedRange . startTime . format ( "HH:mm:ss" ) }
480+ slotMaxTime = { tmpAllowedRange . endTime . format ( "HH:mm:ss" ) }
483481 headerToolbar = { headerToolbar }
484482 views = { views }
485483 initialView = "timeGrid"
@@ -490,6 +488,7 @@ export const Calendar = ({
490488 eventDidMount = { handleEventDidMount }
491489 eventContent = { handleEventContent }
492490 datesSet = { handleDatesSet }
491+ timeZone = "Asia/Tokyo"
493492 />
494493 < Tooltip
495494 key = { tooltipKey }
@@ -516,10 +515,12 @@ function displaySelection(
516515 // 通常の selection では矩形選択ができないため、イベントを作成することで選択範囲を表現している。
517516 // https://github.com/fullcalendar/fullcalendar/issues/4119
518517
518+ const { from, to } = normalizeVertexes ( dayjs . utc ( info . start ) . tz ( ) , dayjs . utc ( info . end ) . tz ( ) ) ;
519+
519520 if ( isSelectionDeleting . current === null ) {
520521 // ドラッグ開始地点が既存の自分のイベントなら削除モード、そうでなければ追加モードとする。
521522 // isSelectionDeleting は select の発火時 (つまり、ドラッグが終了した際) に null にリセットされる。
522- isSelectionDeleting . current = myMatrixRef . current . getIsSlotExist ( info . start ) ;
523+ isSelectionDeleting . current = myMatrixRef . current . getIsSlotExist ( from ) ;
523524 }
524525
525526 if ( ! calendarRef . current ) return false ;
@@ -531,23 +532,6 @@ function displaySelection(
531532 existingSelection . remove ( ) ;
532533 }
533534
534- // start と end が逆転している場合は入れ替える (TODO: refactor)
535- let startTime = info . start . toLocaleTimeString ( "ja-JP" , {
536- hour : "2-digit" ,
537- minute : "2-digit" ,
538- } ) ;
539- let endTime = info . end . toLocaleTimeString ( "ja-JP" , {
540- hour : "2-digit" ,
541- minute : "2-digit" ,
542- } ) ;
543-
544- if (
545- info . start . getHours ( ) > info . end . getHours ( ) ||
546- ( info . start . getHours ( ) === info . end . getHours ( ) && info . start . getMinutes ( ) > info . end . getMinutes ( ) )
547- ) {
548- [ startTime , endTime ] = [ endTime , startTime ] ;
549- }
550-
551535 // 現在選択されている参加形態の色を取得
552536 const currentOption = participationOptions . find ( ( o ) => o . id === currentParticipationOptionId ) ;
553537 const baseColor = currentOption ? currentOption . color : `rgb(${ PRIMARY_RGB . join ( "," ) } )` ;
@@ -561,10 +545,10 @@ function displaySelection(
561545 calendarApi . addEvent ( {
562546 id : SELECT_EVENT ,
563547 className : isSelectionDeleting . current ? DELETE_SELECT_EVENT : CREATE_SELECT_EVENT ,
564- startTime : startTime ,
565- endTime : endTime ,
566- startRecur : info . start ,
567- endRecur : info . end ,
548+ startTime : from . format ( "HH:mm" ) ,
549+ endTime : to . format ( "HH:mm" ) ,
550+ startRecur : from . startOf ( "day" ) . format ( "YYYY-MM-DD" ) ,
551+ endRecur : to . startOf ( "day" ) . add ( 1 , "day" ) . format ( "YYYY-MM-DD" ) ,
568552 display : "background" ,
569553 backgroundColor : backgroundColor ,
570554 borderColor : borderColor ,
@@ -573,22 +557,24 @@ function displaySelection(
573557}
574558
575559/**
576- * 矩形選択した際の左上と右下の頂点を返す。from < to が前提
560+ * 矩形選択の始点・終点を、左上(=日付も時刻も早い)・右下(=日付も時刻も遅い)に正規化して返す。
561+ * FullCalendar の返す selection を矩形選択に利用するために使用。
562+ * なお FullCalendar は逆向きに選択した場合、時間順に入れ替えて from, to を渡してくるので from < to は常に満たされる
577563 */
578- function getVertexes ( from : Date , to : Date ) {
579- if ( from > to ) {
564+ function normalizeVertexes ( from : Dayjs , to : Dayjs ) {
565+ if ( ! from . isBefore ( to ) ) {
580566 throw new Error ( "from < to is required" ) ;
581567 }
582- const needSwap = dayjs ( from ) . format ( "HH:mm" ) > dayjs ( to ) . format ( "HH:mm" ) ;
583- if ( ! needSwap ) {
568+ const fromTime = from . hour ( ) * 60 + from . minute ( ) ;
569+ const toTime = to . hour ( ) * 60 + to . minute ( ) ;
570+
571+ if ( fromTime < toTime ) {
572+ // from の時刻 < to の時刻なら、そのまま返す
584573 return { from, to } ;
585574 }
586-
587- const fromMinute = dayjs ( from ) . hour ( ) * 60 + dayjs ( from ) . minute ( ) ;
588- const toMinute = dayjs ( to ) . hour ( ) * 60 + dayjs ( to ) . minute ( ) ;
589-
590- return {
591- from : dayjs ( from ) . startOf ( "day" ) . add ( toMinute , "minute" ) . toDate ( ) ,
592- to : dayjs ( to ) . startOf ( "day" ) . add ( fromMinute , "minute" ) . toDate ( ) ,
593- } ;
575+ // from の時刻 >= to の時刻の場合、矩形選択の左上と右上の点を算出しそれを新たな from, to として返す。
576+ // fullcalendar は [from, to) で返してくるので、swap 時はそれぞれ 1 セル (=15分) ずらすことが必要。
577+ const newFrom = from . startOf ( "day" ) . add ( toTime , "minute" ) . subtract ( 15 , "minute" ) ;
578+ const newTo = to . startOf ( "day" ) . add ( fromTime , "minute" ) . add ( 15 , "minute" ) ;
579+ return { from : newFrom , to : newTo } ;
594580}
0 commit comments