Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
207 changes: 138 additions & 69 deletions src/components/groups/group-member-list.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,110 @@
"use client";

import { nudgePendingMembers } from "@actions/meeting/nudge/action";
import { Add, ArrowBack, People, Settings, Share } from "@mui/icons-material";
import {
Avatar,
Box,
Button,
Dialog,
DialogContent,
DialogTitle,
IconButton,
MenuItem,
Tab,
Tabs,
Typography,
} from "@mui/material";
import Link from "next/link";
import { useMemo, useState } from "react";
import { FilterChip } from "@/components/ui/filter-chip";
import MeetingCard from "@/components/ui/meeting-card";
import type { SelectGroup } from "@/db/schema";
import { useIsMobile } from "@/hooks/use-mobile";
import { toMeetingCardProps } from "@/lib/meeting-card/mapper";
import { buildScheduledLabel } from "@/lib/meetings/utils";
import type { MeetingWithStats } from "@/server/data/groups/queries";
import { useSnackbar } from "../ui/snackbar-provider";
import { AddMemberDialog } from "./add-member-dialog";
import { GroupSettingsForm } from "./group-settings-form";
import { MeetingRow } from "./meeting-row";
import { MembersList } from "./members-list";
import type { GroupMember } from "./types";

const cardGridSx = {
display: { xs: "flex", sm: "grid" } as const,
flexDirection: "column" as const,
gridTemplateColumns: { sm: "repeat(2, 1fr)", lg: "repeat(3, 1fr)" },
gap: { xs: 1.5, sm: 2 },
};

function GroupMeetingCard({
meeting,
currentMemberId,
status,
}: {
meeting: MeetingWithStats;
currentMemberId: string;
status: "actionRequired" | "upcoming" | null;
}) {
const [isNudging, setIsNudging] = useState(false);
const { showSuccess, showError } = useSnackbar();
const isOwner = meeting.hostId === currentMemberId;

const scheduledLabel =
meeting.scheduledDate &&
meeting.scheduledFromTime &&
meeting.scheduledToTime
? buildScheduledLabel(
meeting.scheduledDate,
meeting.scheduledFromTime,
meeting.scheduledToTime,
)
: undefined;

const cardProps = toMeetingCardProps(
{ ...meeting, hostDisplayName: meeting.hostName },
{ responderCount: meeting.respondedCount, scheduledLabel },
);

const nudgeItem = isOwner ? (
<MenuItem
disabled={isNudging}
onClick={async (e) => {
e.stopPropagation();
setIsNudging(true);
try {
const result = await nudgePendingMembers(meeting.id);
if (result.success) showSuccess(result.message);
else showError(result.message);
} catch {
showError("Failed to send nudge. Please try again.");
} finally {
setIsNudging(false);
}
}}
>
<People sx={{ mr: 1, fontSize: 16 }} />
Nudge Members
</MenuItem>
) : undefined;

return (
<MeetingCard
{...cardProps}
scheduled={Boolean(meeting.scheduledDate)}
isOwner={isOwner}
totalMembers={meeting.totalMembers}
needsAvailability={status === "actionRequired"}
allAvailabilityFilled={meeting.respondedCount >= meeting.totalMembers}
isUpcoming={status === "upcoming"}
isPast={Boolean(
meeting.scheduledDate && new Date(meeting.scheduledDate) < new Date(),
)}
Comment thread
Windslash123 marked this conversation as resolved.
extraMenuItems={nudgeItem}
/>
);
}

interface GroupMemberListProps {
group: SelectGroup;
members: GroupMember[];
Expand All @@ -49,34 +130,47 @@ export function GroupMemberList({
"all" | "created" | "upcoming"
>("all");

const {
meetingsPendingAvailability,
meetingsPendingAvailabilityMap,
allMeetings,
} = useMemo(() => {
const getSortDate = (meeting: MeetingWithStats) =>
meeting.scheduledDate
? new Date(meeting.scheduledDate)
: new Date(meeting.dates[0]);

const meetingsPendingAvailability = meetings
.filter((meeting) => !meeting.userHasResponded)
.sort((a, b) => getSortDate(a).getTime() - getSortDate(b).getTime());

const meetingsPendingAvailabilityMap = new Set(
meetingsPendingAvailability.map((m) => m.id),
);
const { allMeetingsSorted, upcomingSet } = useMemo(() => {
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const windowEnd = startOfToday.getTime() + 3 * 24 * 60 * 60 * 1000;

const allMeetings = [...meetings].sort(
(a, b) => getSortDate(a).getTime() - getSortDate(b).getTime(),
const upcomingSet = new Set(
meetings
.filter((m) => {
if (!m.scheduledDate) return false;
const t = m.scheduledDate.getTime();
return t >= startOfToday.getTime() && t <= windowEnd;
})
.map((m) => m.id),
);

return {
meetingsPendingAvailability,
meetingsPendingAvailabilityMap,
allMeetings,
const getPriority = (m: MeetingWithStats) => {
if (!m.userHasResponded && !m.scheduledDate) return 0;
if (
m.respondedCount >= m.totalMembers &&
m.hostId === currentMemberId &&
!m.scheduledDate
)
return 1;
if (upcomingSet.has(m.id)) return 2;
if (m.scheduledDate) return 3;
return 4;
};
}, [meetings]);

const getSortDate = (m: MeetingWithStats) =>
m.scheduledDate
? m.scheduledDate.getTime()
: new Date(m.dates[0]).getTime();

const allMeetingsSorted = [...meetings].sort((a, b) => {
const pDiff = getPriority(a) - getPriority(b);
if (pDiff !== 0) return pDiff;
return getSortDate(a) - getSortDate(b);
});

return { allMeetingsSorted, upcomingSet };
}, [meetings, currentMemberId]);

const counts = {
all: meetings.length,
Expand All @@ -86,7 +180,7 @@ export function GroupMemberList({
).length,
};

const displayedMeetings = allMeetings.filter((meeting) => {
const displayedMeetings = allMeetingsSorted.filter((meeting) => {
if (activeFilter === "created") return meeting.hostId === currentMemberId;
if (activeFilter === "upcoming") {
return (
Expand Down Expand Up @@ -261,49 +355,24 @@ export function GroupMemberList({
{tab === 0 && (
<div className="mt-6">
{meetings.length > 0 ? (
<div>
<div className="mt-8">
<p className="mb-2 font-bold text-[#969696] text-xs uppercase tracking-wide">
Action Required ({meetingsPendingAvailability.length})
</p>
{meetingsPendingAvailability.map((meeting) => (
<div
key={meeting.id}
className="mb-3 rounded-xl border border-gray-300"
>
<MeetingRow
meeting={meeting}
currentMemberId={currentMemberId}
status="actionRequired"
/>
</div>
))}
</div>
<div className="mt-8">
<p className="mb-5 font-bold text-[#969696] text-xs uppercase tracking-wide">
All ({displayedMeetings.length})
</p>
{displayedMeetings.map((meeting) => (
<div
key={meeting.id}
className="mb-3 rounded-xl border border-gray-300"
>
<MeetingRow
meeting={meeting}
currentMemberId={currentMemberId}
status={
meetingsPendingAvailabilityMap.has(meeting.id)
? "actionRequired"
: meeting.scheduledDate &&
new Date(meeting.scheduledDate) > new Date()
? "upcoming"
: null
}
/>
</div>
))}
</div>
</div>
<Box sx={cardGridSx}>
{displayedMeetings.map((meeting) => (
<GroupMeetingCard
Comment thread
Windslash123 marked this conversation as resolved.
key={meeting.id}
meeting={meeting}
currentMemberId={currentMemberId}
status={
meeting.scheduledDate
? upcomingSet.has(meeting.id)
? "upcoming"
: null
: !meeting.userHasResponded
? "actionRequired"
: null
}
/>
))}
</Box>
) : (
<div className="flex items-center justify-center py-32">
<p className="text-gray-500 text-lg italic">No meetings yet!</p>
Expand Down
38 changes: 26 additions & 12 deletions src/components/ui/meeting-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ import {
Typography,
} from "@mui/material";
import Link from "next/link";
import { type ElementType, useId, useState } from "react";
import { type ElementType, type ReactNode, useId, useState } from "react";
import type { MeetingCardViewModel } from "@/lib/meeting-card/mapper";
import { getDeleteLeaveAction } from "@/lib/meetings/delete-leave-action";

interface MeetingCardProps extends MeetingCardViewModel {
isOwner: boolean;
onDeleteLeave?: () => void;
extraMenuItems?: ReactNode;
totalMembers?: number;
needsAvailability?: boolean;
allAvailabilityFilled?: boolean;
isUpcoming?: boolean;
Expand Down Expand Up @@ -76,6 +78,8 @@ const MeetingCard = ({
meetingLink,
isOwner,
onDeleteLeave,
extraMenuItems,
totalMembers,
needsAvailability = false,
allAvailabilityFilled = false,
isUpcoming = false,
Expand Down Expand Up @@ -140,7 +144,7 @@ const MeetingCard = ({
{meetingOrganizer}
</Typography>
</Box>
{onDeleteLeave && (
{(onDeleteLeave || extraMenuItems) && (
<>
<IconButton
size="small"
Expand Down Expand Up @@ -169,15 +173,18 @@ const MeetingCard = ({
paper: { sx: { minWidth: 180 } },
}}
>
<MenuItem
onClick={handleOpenDeleteLeave}
sx={{ color: menuColor }}
>
<ListItemIcon sx={{ color: "inherit", minWidth: 36 }}>
<Icon fontSize="small" />
</ListItemIcon>
{actionLabel}
</MenuItem>
{onDeleteLeave && (
<MenuItem
onClick={handleOpenDeleteLeave}
sx={{ color: menuColor }}
>
<ListItemIcon sx={{ color: "inherit", minWidth: 36 }}>
<Icon fontSize="small" />
</ListItemIcon>
{actionLabel}
</MenuItem>
)}
{extraMenuItems}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
</Menu>
</>
)}
Expand All @@ -186,7 +193,14 @@ const MeetingCard = ({
<Box sx={metaGridSx}>
<MetaItem icon={DateRangeIcon} label={dateLabel} />
<MetaItem icon={AccessTimeIcon} label={`${timeStart} - ${timeEnd}`} />
<MetaItem icon={GroupIcon} label={`${numResponders} Responders`} />
<MetaItem
icon={GroupIcon}
label={
totalMembers !== undefined
? `${numResponders}/${totalMembers} Responders`
: `${numResponders} Responders`
}
/>
{location && <MetaItem icon={FmdGoodIcon} label={location} />}
</Box>
</CardContent>
Expand Down
15 changes: 13 additions & 2 deletions src/server/data/groups/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ export async function getGroupsWithDetails(
export type MeetingWithStats = SelectMeeting & {
hostName: string;
scheduledDate: Date | null;
scheduledFromTime: string | null;
scheduledToTime: string | null;
totalMembers: number;
respondedCount: number;
userHasResponded: boolean;
Expand Down Expand Up @@ -327,14 +329,23 @@ export async function getGroupMeetingsWithStats(
);
const userRespondedSet = new Set(userResponses.map((r) => r.meetingId));
const scheduledMap = new Map(
scheduledBlocks.map((s) => [s.meetingId, s.scheduledDate]),
scheduledBlocks.map((s) => [
s.meetingId,
{
scheduledDate: s.scheduledDate,
scheduledFromTime: s.scheduledFromTime,
scheduledToTime: s.scheduledToTime,
},
]),
);
const hostMap = new Map(hosts.map((h) => [h.id, h.displayName]));

return groupMeetings.map((meeting) => ({
...meeting,
hostName: hostMap.get(meeting.hostId) ?? "Unknown",
scheduledDate: scheduledMap.get(meeting.id) ?? null,
scheduledDate: scheduledMap.get(meeting.id)?.scheduledDate ?? null,
scheduledFromTime: scheduledMap.get(meeting.id)?.scheduledFromTime ?? null,
scheduledToTime: scheduledMap.get(meeting.id)?.scheduledToTime ?? null,
totalMembers,
respondedCount: responseMap.get(meeting.id) ?? 0,
userHasResponded: userRespondedSet.has(meeting.id),
Expand Down
Loading