Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions client/src/components/Calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ type ParticipationOption = {
color: string;
};

/**
* ハイライト条件。同時に有効なのは 1 つだけ。
*/
export type Highlight = { type: "maxCount" } | { type: "guest"; guestId: string };

type Props = {
startDate: Dayjs;
endDate: Dayjs;
Expand All @@ -33,6 +38,7 @@ type Props = {
guestIdToComment: Record<string, string>;
participationOptions: ParticipationOption[];
currentParticipationOptionId: string;
highlight: Highlight | null;
editMode: boolean;
onChangeEditingSlots: (slots: EditingSlot[]) => void;
};
Expand Down Expand Up @@ -72,6 +78,7 @@ export const Calendar = ({
guestIdToComment,
participationOptions,
currentParticipationOptionId,
highlight,
editMode,
onChangeEditingSlots,
}: Props) => {
Expand Down Expand Up @@ -112,14 +119,28 @@ export const Calendar = ({
}, [editingSlots]);

// viewingSlots → ViewingMatrix → rendered slots
const computedViewingSlots = useMemo(() => {
const viewingMatrix = useMemo(() => {
const matrix = new ViewingMatrix(countDays, startDate);
for (const slot of viewingSlots) {
matrix.setGuestRange(slot.from, slot.to, slot.guestId, slot.optionId);
}
return matrix.getSlots();
return matrix;
}, [viewingSlots, countDays, startDate]);

const computedViewingSlots = useMemo(() => viewingMatrix.getSlots(), [viewingMatrix]);

// ハイライト条件を満たすセルを求め、連続区間にまとめる
const highlightSlots = useMemo(() => {
if (!highlight) return [];
if (highlight.type === "guest") {
const { guestId } = highlight;
return viewingMatrix.buildHighlight((cell) => guestId in cell).getSlots();
}
const maxCount = viewingMatrix.getMaxGuestCount();
if (maxCount === 0) return [];
return viewingMatrix.buildHighlight((cell) => Object.keys(cell).length === maxCount).getSlots();
}, [viewingMatrix, highlight]);

// セル座標変換ヘルパー(毎レンダーで最新クロージャを利用)
const xyToCell = (x: number, y: number) => {
const el = gridRef.current;
Expand All @@ -139,6 +160,39 @@ export const Calendar = ({

const toSlotIdx = (dt: Dayjs) => (dt.hour() * 60 + dt.minute() - slotStartMinutes) / 15;

/**
* ハイライトの「補集合」を矩形として列挙する。ここを白ベールで覆うことで、
* 既存の(参加形態の色 × 人数の濃さ)表現を汚さずに該当区間だけを浮き上がらせる。
*/
const veilRects = useMemo(() => {
if (highlightSlots.length === 0) return [];

const perDay: { from: number; to: number }[][] = Array.from({ length: countDays }, () => []);
for (const slot of highlightSlots) {
const dayIdx = slot.from.startOf("day").diff(startDate.startOf("day"), "day");
if (dayIdx < 0 || dayIdx >= countDays) continue;
const rawFrom = (slot.from.hour() * 60 + slot.from.minute() - slotStartMinutes) / 15;
// to は 24:00(翌日 0:00)になりうるので、from からの経過時間で求める
const rawTo = rawFrom + slot.to.diff(slot.from, "minute") / 15;
const from = Math.max(0, rawFrom);
const to = Math.min(slotCount, rawTo);
if (to <= from) continue;
perDay[dayIdx].push({ from, to });
}

const rects: { day: number; from: number; to: number }[] = [];
for (let day = 0; day < countDays; day++) {
const ranges = perDay[day].sort((a, b) => a.from - b.from);
let cursor = 0;
for (const range of ranges) {
if (range.from > cursor) rects.push({ day, from: cursor, to: range.from });
cursor = Math.max(cursor, range.to);
}
if (cursor < slotCount) rects.push({ day, from: cursor, to: slotCount });
}
return rects;
}, [highlightSlots, countDays, startDate, slotCount, slotStartMinutes]);

updatePreviewRef.current = (x: number, y: number) => {
const cell = xyToCell(x, y);
const s = dragStart.current;
Expand Down Expand Up @@ -431,6 +485,15 @@ export const Calendar = ({
);
})}

{/* ハイライト: 非該当領域を白ベールで落とす(pointer-events-none で内訳ツールチップは維持) */}
{veilRects.map((rect) => (
<div
key={`hl-${rect.day}-${rect.from}`}
className="pointer-events-none absolute bg-white/65"
style={pct(rect.day, rect.from, 1, rect.to - rect.from)}
/>
))}

{/* 編集中スロット(自分の登録済み時間) */}
{slots.map((slot) => {
const dayIdx = slot.from.startOf("day").diff(startDate.startOf("day"), "day");
Expand Down
63 changes: 62 additions & 1 deletion client/src/lib/CalendarMatrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ export type EditingMatrixSlot = {
optionId: string;
};

export type HighlightMatrixSlot = {
from: Dayjs;
to: Dayjs;
};

export type ViewingMatrixSlot = {
from: Dayjs;
to: Dayjs;
Expand Down Expand Up @@ -60,7 +65,7 @@ abstract class CalendarMatrixBase<T> {
Array.from({ length: this.quarterCount }, () => null),
);
}
abstract getSlots(): EditingMatrixSlot[] | ViewingMatrixSlot[];
abstract getSlots(): EditingMatrixSlot[] | ViewingMatrixSlot[] | HighlightMatrixSlot[];
}

/**
Expand Down Expand Up @@ -104,6 +109,38 @@ export class ViewingMatrix extends CalendarMatrixBase<Record<string, string>> {
}
}

/**
* いずれかのゲストが登録しているセルのうち、最も参加人数が多いセルの人数を返す。
* 誰も登録していない場合は 0。
*/
getMaxGuestCount(): number {
let max = 0;
for (const row of this.matrix) {
for (const cell of row) {
if (cell === null) continue;
const count = Object.keys(cell).length;
if (count > max) max = count;
}
}
return max;
}

/**
* 各セルに述語を適用し、条件を満たすセルだけを立てた {@link HighlightMatrix} を返す。
* セル単位で判定してから run 化するため、連続区間が正しくまとまる。
*/
buildHighlight(predicate: (cell: Record<string, string>) => boolean): HighlightMatrix {
const highlight = new HighlightMatrix(this.matrix.length, this.initialDatetime);
for (let day = 0; day < this.matrix.length; day++) {
for (let quarter = 0; quarter < this.quarterCount; quarter++) {
const cell = this.matrix[day][quarter];
if (cell === null || !predicate(cell)) continue;
highlight.mark(day, quarter);
}
}
return highlight;
}

getSlots(): ViewingMatrixSlot[] {
const slots: ViewingMatrixSlot[] = [];
for (let day = 0; day < this.matrix.length; day++) {
Expand All @@ -126,6 +163,30 @@ export class ViewingMatrix extends CalendarMatrixBase<Record<string, string>> {
}
}

/**
* ハイライト対象セルの {@link CalendarMatrixBase}。セル値は「対象である」ことのみを表す。
*/
export class HighlightMatrix extends CalendarMatrixBase<true> {
mark(day: number, quarter: number): void {
if (!this.isInBounds(day, quarter)) return;
this.matrix[day][quarter] = true;
}

getSlots(): HighlightMatrixSlot[] {
const slots: HighlightMatrixSlot[] = [];
for (let day = 0; day < this.matrix.length; day++) {
const runs = findRuns(this.matrix[day], () => true);
for (const run of runs) {
slots.push({
from: this.initialDatetime.add(day, "day").add(run.start * 15, "minute"),
to: this.initialDatetime.add(day, "day").add(run.end * 15, "minute"),
});
}
}
return slots;
}
}

function isSameRecordShallow(a: Record<string, string>, b: Record<string, string>): boolean {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
Expand Down
66 changes: 53 additions & 13 deletions client/src/pages/eventId/Submission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import {
LuSend,
LuSettings2,
LuUser,
LuUsers,
LuX,
} from "react-icons/lu";
import { NavLink, useParams } from "react-router";
import type { AppType } from "../../../../server/src/main";
import { AddToCalendar } from "../../components/AddToCalendar";
import { Calendar } from "../../components/Calendar";
import { Calendar, type Highlight } from "../../components/Calendar";
import Header from "../../components/Header";
import { projectReviver } from "../../revivers";
import type { Project, Slot } from "../../types";
Expand Down Expand Up @@ -146,6 +147,8 @@ export default function SubmissionPage() {

const [comment, setComment] = useState(meAsGuest?.comment ?? "");

const [highlight, setHighlight] = useState<Highlight | null>(null);

const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [guestListExpanded, setGuestListExpanded] = useState(false);

Expand Down Expand Up @@ -229,6 +232,11 @@ export default function SubmissionPage() {
}
}, [meAsGuest]);

// 編集・確認モードではベールがドラッグ入力の邪魔になるため解除する
useEffect(() => {
if (mode !== "view") setHighlight(null);
}, [mode]);

const guestIdToName = useMemo(() => {
if (!project) return {};
return Object.fromEntries(project.guests.map((g) => [g.id, g.name]));
Expand Down Expand Up @@ -394,6 +402,27 @@ export default function SubmissionPage() {
</div>
)}

{/* ハイライト操作バー */}
{mode === "view" && project.guests.length > 0 && (
<div className="mt-3 mb-2 flex flex-wrap items-center gap-1.5">
<button
type="button"
className={`btn btn-sm gap-1.5 ${highlight?.type === "maxCount" ? "btn-primary" : "btn-outline"}`}
onClick={() => setHighlight((prev) => (prev?.type === "maxCount" ? null : { type: "maxCount" }))}
>
<LuUsers className="h-4 w-4" />
最多人数
</button>
{highlight?.type === "guest" && (
<button type="button" className="btn btn-sm btn-primary gap-1.5" onClick={() => setHighlight(null)}>
<LuUser className="h-4 w-4" />
{guestIdToName[highlight.guestId] ?? "参加者"}さんの日程
<LuX className="h-4 w-4" />
</button>
)}
</div>
)}

<Calendar
startDate={project.startDate}
endDate={project.endDate}
Expand All @@ -404,6 +433,7 @@ export default function SubmissionPage() {
guestIdToComment={guestIdToComment}
participationOptions={project.participationOptions}
currentParticipationOptionId={selectedParticipationOptionId}
highlight={mode === "view" ? highlight : null}
editMode={mode === "edit"}
onChangeEditingSlots={setEditingSlots}
/>
Expand All @@ -423,19 +453,29 @@ export default function SubmissionPage() {
<ul className="mt-1 divide-y divide-base-200">
{project.guests.map((guest) => {
const commentText = guestIdToComment[guest.id];
const isHighlighted = highlight?.type === "guest" && highlight.guestId === guest.id;
return (
<li key={guest.id} className="flex items-start gap-3 py-2">
<div className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-full bg-base-300">
<LuUser className="h-4 w-4 text-base-content/40" />
</div>
<div className="min-w-0 flex-1">
<p className="pt-1 font-medium text-base-content text-sm">{guest.name}</p>
{commentText && (
<div className="mt-1.5 w-fit max-w-full rounded-2xl rounded-tl-none bg-base-300 px-3 py-2 text-base-content text-sm">
<span className="wrap-break-word whitespace-pre-wrap">{commentText}</span>
</div>
)}
</div>
<li key={guest.id}>
<button
type="button"
aria-pressed={isHighlighted}
onClick={() => setHighlight(isHighlighted ? null : { type: "guest", guestId: guest.id })}
className={`flex w-full items-start gap-3 rounded-lg px-2 py-2 text-left transition-colors ${
isHighlighted ? "bg-primary/10" : "hover:bg-base-200"
}`}
>
<div className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-full bg-base-300">
<LuUser className="h-4 w-4 text-base-content/40" />
</div>
<div className="min-w-0 flex-1">
<p className="pt-1 font-medium text-base-content text-sm">{guest.name}</p>
{commentText && (
<div className="mt-1.5 w-fit max-w-full rounded-2xl rounded-tl-none bg-base-300 px-3 py-2 text-base-content text-sm">
<span className="wrap-break-word whitespace-pre-wrap">{commentText}</span>
</div>
)}
</div>
</button>
</li>
);
})}
Expand Down
Loading