Skip to content

Commit 2558f00

Browse files
authored
feat: active cell and if-needed behavior (#474)
* fix: πŸ› centralize active range * feat: ✨ deselect and if-needed italics * fix: πŸ› increment if-needed * chore: πŸ”§ rename unavailability * refactor: ♻️ marker attribute * chore: πŸ”§ remove dead code * refactor: ♻️ separate tuple function * chore: πŸ”§ follow Tailwind * fix: πŸ› mui chips
1 parent 1fc4afc commit 2558f00

8 files changed

Lines changed: 189 additions & 129 deletions

File tree

β€Žsrc/components/availability/availability.tsxβ€Ž

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,6 @@ export function Availability({
137137
googleCalendarEvents,
138138
members,
139139
pendingMembers,
140-
anchorNormalizedDate,
141140
importGridIsoSet,
142141
doesntNeedDay,
143142
currentPageAvailability,
@@ -283,7 +282,7 @@ export function Availability({
283282
<div className="shrink-0 lg:hidden">
284283
<AvailabilityActions {...actionsProps} />
285284
</div>
286-
<table className="w-full table-fixed">
285+
<table data-availability-grid="" className="w-full table-fixed">
287286
<AvailabilityTableHeader
288287
currentPageAvailability={currentPageAvailability}
289288
meetingType={meetingData.meetingType}
@@ -349,13 +348,12 @@ export function Availability({
349348
>
350349
<GroupResponses
351350
availabilityDates={availabilityDates}
351+
ifNeededDates={ifNeededDates}
352352
fromTime={fromTimeMinutes}
353353
members={members}
354354
pendingMembers={pendingMembers}
355355
timezone={userTimezone}
356-
anchorNormalizedDate={anchorNormalizedDate}
357356
currentPageAvailability={currentPageAvailability}
358-
availabilityTimeBlocks={availabilityTimeBlocks}
359357
doesntNeedDay={doesntNeedDay}
360358
/>
361359
</Paper>

β€Žsrc/components/availability/group-responses.tsxβ€Ž

Lines changed: 92 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,29 @@ import { Avatar, Button, Chip, Switch, Typography } from "@mui/material/";
22
import { XIcon } from "lucide-react";
33
import { useCallback, useMemo } from "react";
44
import { useShallow } from "zustand/shallow";
5-
import { computeGroupMembersForRange } from "@/lib/availability/group-query";
6-
import { newZonedPageAvailAndDates } from "@/lib/availability/utils";
5+
import {
6+
computeGroupMembersForRange,
7+
type GroupMembersForRange,
8+
type MemberRangeStatus,
9+
statusForMember,
10+
} from "@/lib/availability/group-query";
11+
import { cloneDates } from "@/lib/availability/utils";
712
import type { Member, SelectionStateType } from "@/lib/types/availability";
813
import { cn } from "@/lib/utils";
914
import { ZotDate } from "@/lib/zotdate";
10-
import { useAvailabilityStore } from "@/store/useAvailabilityStore";
15+
import {
16+
useActiveSelectionRange,
17+
useAvailabilityStore,
18+
} from "@/store/useAvailabilityStore";
1119

1220
const EN_DASH = "\u2013";
1321

22+
const RESPONDER_CHIP_CLASS: Record<MemberRangeStatus, string> = {
23+
available: "",
24+
"if-needed": "[&_.MuiChip-label]:italic",
25+
unavailable: "[&_.MuiChip-label]:line-through",
26+
};
27+
1428
/**
1529
* Formats a selection range for display in the sidebar. The range is a
1630
* rectangle in (day, time) space, not a continuous interval, so the day and
@@ -50,42 +64,48 @@ function formatRangeLabel(range: SelectionStateType, dates: ZotDate[]): string {
5064

5165
interface GroupResponsesProps {
5266
availabilityDates: ZotDate[];
67+
ifNeededDates: ZotDate[];
5368
members: Member[];
5469
pendingMembers: Member[];
5570
fromTime: number;
5671
timezone: string;
57-
anchorNormalizedDate: Date[];
5872
currentPageAvailability: {
5973
availabilities: (ZotDate | null)[];
6074
ifNeeded: (ZotDate | null)[];
6175
};
62-
availabilityTimeBlocks: number[];
6376
doesntNeedDay: boolean;
6477
}
6578

6679
export function GroupResponses({
6780
availabilityDates,
81+
ifNeededDates,
6882
fromTime,
6983
members,
7084
pendingMembers,
7185
timezone,
7286
currentPageAvailability,
7387
doesntNeedDay,
7488
}: GroupResponsesProps) {
75-
const [_newBlocks, newAvailDates] = newZonedPageAvailAndDates(
76-
currentPageAvailability.availabilities,
77-
availabilityDates,
78-
doesntNeedDay,
89+
const newAvailDates = useMemo(
90+
() =>
91+
cloneDates(
92+
currentPageAvailability.availabilities,
93+
availabilityDates,
94+
doesntNeedDay,
95+
),
96+
[currentPageAvailability.availabilities, availabilityDates, doesntNeedDay],
7997
);
80-
const [_newIfNeededBlocks, newIfNeededDates] = newZonedPageAvailAndDates(
81-
currentPageAvailability.ifNeeded,
82-
availabilityDates,
83-
doesntNeedDay,
98+
const newIfNeededDates = useMemo(
99+
() =>
100+
cloneDates(
101+
currentPageAvailability.ifNeeded,
102+
ifNeededDates,
103+
doesntNeedDay,
104+
),
105+
[currentPageAvailability.ifNeeded, ifNeededDates, doesntNeedDay],
84106
);
85107

86108
const {
87-
draftRange,
88-
committedRange,
89109
isMobileDrawerOpen,
90110
setIsMobileDrawerOpen,
91111
setHoveredMember,
@@ -96,8 +116,6 @@ export function GroupResponses({
96116
setEnabled: setShowBestTimes,
97117
} = useAvailabilityStore(
98118
useShallow((state) => ({
99-
draftRange: state.draftRange,
100-
committedRange: state.committedRange,
101119
isMobileDrawerOpen: state.isMobileDrawerOpen,
102120
setIsMobileDrawerOpen: state.setIsMobileDrawerOpen,
103121
setHoveredMember: state.setHoveredMember,
@@ -109,7 +127,7 @@ export function GroupResponses({
109127
})),
110128
);
111129

112-
const activeRange = draftRange ?? committedRange;
130+
const activeRange = useActiveSelectionRange();
113131

114132
const handleClearSelected = useCallback(() => {
115133
setSelectedMember([]);
@@ -129,39 +147,46 @@ export function GroupResponses({
129147
[toggleSelectedMember],
130148
);
131149

132-
const availableMembers = useMemo(() => {
133-
if (!activeRange) return [] as Member[];
134-
const { availableMemberIds } = computeGroupMembersForRange({
150+
const respondedMembers = useMemo(() => {
151+
const pendingMemberIds = new Set(pendingMembers.map((m) => m.memberId));
152+
return members.filter((member) => !pendingMemberIds.has(member.memberId));
153+
}, [members, pendingMembers]);
154+
155+
const groups = useMemo<GroupMembersForRange | null>(() => {
156+
if (!activeRange) return null;
157+
return computeGroupMembersForRange({
135158
range: activeRange,
136159
availabilityDates: newAvailDates,
137160
ifNeededDates: newIfNeededDates,
138161
fromTimeMinutes: fromTime,
139162
timeZone: timezone,
140163
});
141-
const byId = new Map(members.map((m) => [m.memberId, m]));
142-
return availableMemberIds
143-
.map((id) => byId.get(id))
144-
.filter((m): m is Member => Boolean(m));
145-
}, [
146-
activeRange,
147-
newAvailDates,
148-
newIfNeededDates,
149-
fromTime,
150-
timezone,
151-
members,
152-
]);
164+
}, [activeRange, newAvailDates, newIfNeededDates, fromTime, timezone]);
153165

154-
const respondedMembers = useMemo(() => {
155-
const pendingMemberIds = new Set(pendingMembers.map((m) => m.memberId));
156-
return members.filter((member) => !pendingMemberIds.has(member.memberId));
157-
}, [members, pendingMembers]);
166+
const memberStatus = useMemo<ReadonlyMap<string, MemberRangeStatus>>(() => {
167+
if (!groups) return new Map();
168+
const map = new Map<string, MemberRangeStatus>();
169+
for (const m of respondedMembers) {
170+
map.set(m.memberId, statusForMember(m.memberId, groups));
171+
}
172+
return map;
173+
}, [groups, respondedMembers]);
174+
175+
const availableCount = activeRange
176+
? respondedMembers.filter(
177+
(m) => memberStatus.get(m.memberId) !== "unavailable",
178+
).length
179+
: respondedMembers.length;
158180

159181
const blockInfoString = activeRange
160182
? formatRangeLabel(activeRange, newAvailDates)
161183
: "Select a cell to view";
162184

163185
return (
164-
<div className="flex min-h-0 min-w-0 flex-1 flex-col lg:shrink-0">
186+
<div
187+
data-availability-sidebar=""
188+
className="flex min-h-0 min-w-0 flex-1 flex-col lg:shrink-0"
189+
>
165190
<div
166191
className={cn(
167192
// Cap height so the flex row does not grow with responder count (see availability layout).
@@ -221,43 +246,39 @@ export function GroupResponses({
221246
<div>
222247
<h2 className="font-medium text-xl">Responders</h2>
223248
<Typography variant="caption" color="textSecondary">
224-
Available (
225-
{activeRange ? availableMembers.length : respondedMembers.length})
249+
Available ({availableCount})
226250
</Typography>
227251
</div>
228252

229253
<ul className="mt-3 flex flex-wrap gap-2">
230-
{respondedMembers.map((member) => (
231-
<Chip
232-
key={member.memberId}
233-
clickable
234-
icon={
235-
<Avatar
236-
alt={member.displayName}
237-
src={member.profilePicture ?? undefined}
238-
slotProps={{ img: { referrerPolicy: "no-referrer" } }}
239-
sx={{ width: 24, height: 24, fontSize: 12 }}
240-
/>
241-
}
242-
label={member.displayName}
243-
color={
244-
selectedMembers.includes(member.memberId)
245-
? "primary"
246-
: "default"
247-
}
248-
variant="outlined"
249-
sx={
250-
activeRange
251-
? availableMembers.includes(member)
252-
? { maxWidth: "100%" }
253-
: { textDecoration: "line-through", maxWidth: "100%" }
254-
: { maxWidth: "100%" }
255-
}
256-
onMouseEnter={() => handleMemberHover(member.memberId)}
257-
onMouseLeave={() => handleMemberHover(null)}
258-
onClick={() => handleMemberSelect(member.memberId)}
259-
/>
260-
))}
254+
{respondedMembers.map((member) => {
255+
const status = memberStatus.get(member.memberId) ?? "available";
256+
return (
257+
<Chip
258+
key={member.memberId}
259+
clickable
260+
icon={
261+
<Avatar
262+
alt={member.displayName}
263+
src={member.profilePicture ?? undefined}
264+
slotProps={{ img: { referrerPolicy: "no-referrer" } }}
265+
sx={{ width: 24, height: 24, fontSize: 12 }}
266+
/>
267+
}
268+
label={member.displayName}
269+
color={
270+
selectedMembers.includes(member.memberId)
271+
? "primary"
272+
: "default"
273+
}
274+
variant="outlined"
275+
className={cn("max-w-full", RESPONDER_CHIP_CLASS[status])}
276+
onMouseEnter={() => handleMemberHover(member.memberId)}
277+
onMouseLeave={() => handleMemberHover(null)}
278+
onClick={() => handleMemberSelect(member.memberId)}
279+
/>
280+
);
281+
})}
261282
</ul>
262283
<div className="mt-4 ml-auto">
263284
<Button

β€Žsrc/components/availability/table/availability-table-header.tsxβ€Ž

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { Typography } from "@mui/material";
22
import React from "react";
33
import type { SelectMeeting } from "@/db/schema";
44
import {
5+
cloneBlocks,
56
formatDateToUSNumeric,
6-
newZonedPageAvailAndDates,
77
spacerBeforeDate,
88
} from "@/lib/availability/utils";
99
import type { ZotDate } from "@/lib/zotdate";
@@ -22,11 +22,8 @@ export function AvailabilityTableHeader({
2222
meetingType,
2323
doesntNeedDay,
2424
}: AvailabilityTableHeaderProps) {
25-
//extra day calculation for day spillover
26-
//put in here to prevent infinite adding, recalculates everytime something changes
27-
const [newBlocks, _newAvailDates] = newZonedPageAvailAndDates(
25+
const newBlocks = cloneBlocks(
2826
currentPageAvailability.availabilities,
29-
null,
3027
doesntNeedDay,
3128
);
3229

β€Žsrc/hooks/use-availability-data.tsβ€Ž

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,6 @@ export interface UseAvailabilityDataResult {
132132
googleCalendarEvents: GoogleCalendarEvent[];
133133
members: Member[];
134134
pendingMembers: Member[];
135-
anchorNormalizedDate: Date[];
136135
importGridIsoSet: ReadonlySet<string>;
137136
doesntNeedDay: boolean;
138137
currentPageAvailability: {
@@ -364,7 +363,6 @@ export function useAvailabilityData({
364363
googleCalendarEvents,
365364
members,
366365
pendingMembers,
367-
anchorNormalizedDate,
368366
importGridIsoSet,
369367
doesntNeedDay,
370368
currentPageAvailability,

β€Žsrc/hooks/use-grid-interaction.tsβ€Ž

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ function rangesEqual(
5050
);
5151
}
5252

53+
function isInsideInteractiveSurface(el: Element | null): boolean {
54+
if (!el) return false;
55+
return Boolean(
56+
el.closest("[data-availability-grid]") ||
57+
el.closest("[data-availability-sidebar]") ||
58+
el.closest('[role="presentation"]'),
59+
);
60+
}
61+
5362
/**
5463
* Owns the interaction half of the availability feature:
5564
* - commit dispatcher (personal paint / schedule replace / group lock-unlock),
@@ -141,6 +150,19 @@ export function useGridInteraction({
141150
setIsMobileDrawerOpen(true);
142151
};
143152

153+
useEffect(() => {
154+
if (availabilityView !== "group") return;
155+
const onDocPointerDown = (e: PointerEvent) => {
156+
if (isInsideInteractiveSurface(e.target as Element | null)) return;
157+
if (useAvailabilityStore.getState().committedRange === undefined) return;
158+
resetSelection();
159+
setIsMobileDrawerOpen(false);
160+
};
161+
document.addEventListener("pointerdown", onDocPointerDown, true);
162+
return () =>
163+
document.removeEventListener("pointerdown", onDocPointerDown, true);
164+
}, [availabilityView, resetSelection, setIsMobileDrawerOpen]);
165+
144166
const handlers = useGridDragSelection({
145167
lockToStartRow: availabilityView === "schedule",
146168
onDragStart: () => {

0 commit comments

Comments
Β (0)