Skip to content

Commit f1ff9fc

Browse files
authored
タイムゾーンを固定 (#77)
* Date 型 → Dayjs 型に変更 * fix: Asia/Tokyoを強制する形に修正 * fix: 選択範囲の始点終点が逆転する際の端点のバグ修正 * chore: 現在はJSTに固定される旨の注記
1 parent 0a2a387 commit f1ff9fc

11 files changed

Lines changed: 212 additions & 153 deletions

File tree

biome.jsonc

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,15 @@
3333
"noUnusedTemplateLiteral": "error",
3434
"useNumberNamespace": "error",
3535
"noInferrableTypes": "error",
36-
"noUselessElse": "error"
36+
"noUselessElse": "error",
37+
"noRestrictedImports": {
38+
"level": "error",
39+
"options": {
40+
"paths": {
41+
"dayjs": "dayjs は lib/dayjs を使用する"
42+
}
43+
}
44+
}
3745
},
3846
"nursery": {
3947
"useSortedClasses": "warn"

client/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
"@cloudflare/pages-plugin-vercel-og": "^0.1.2",
1616
"@fullcalendar/core": "^6.1.15",
1717
"@fullcalendar/interaction": "^6.1.15",
18+
"@fullcalendar/moment-timezone": "^6.1.20",
1819
"@fullcalendar/react": "^6.1.15",
1920
"@fullcalendar/timegrid": "^6.1.15",
2021
"@hookform/resolvers": "^4.1.3",
2122
"@tailwindcss/vite": "^4.0.13",
2223
"dayjs": "^1.11.13",
24+
"moment-timezone": "^0.5.48",
2325
"react": "^19.0.0",
2426
"react-dom": "^19.0.0",
2527
"react-hook-form": "^7.54.2",

client/src/components/Calendar.tsx

Lines changed: 54 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
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";
61
import 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";
1514
import type React from "react";
1615
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1716
import { Tooltip } from "react-tooltip";
1817
import useCalendarScrollBlock from "../hooks/useCalendarScrollBlock";
1918
import { EditingMatrix, ViewingMatrix } from "../lib/CalendarMatrix";
19+
import dayjs, { type Dayjs } from "../lib/dayjs";
2020
import type { EditingSlot } from "../pages/eventId/Submission";
2121

22-
dayjs.locale("ja");
23-
2422
type AllowedRange = {
25-
startTime: Date;
26-
endTime: Date;
23+
startTime: Dayjs;
24+
endTime: Dayjs;
2725
};
2826

2927
type 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

5755
type 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

Comments
 (0)