diff --git a/backend/requirements.txt b/backend/requirements.txt index f9548a77b..1c686fa96 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,6 +16,7 @@ django-environ==0.12.0 django-ses==4.4.0 djangorestframework==3.16.0 gunicorn==23.0.0 +isort==8.0.1 jmespath==1.0.1 kombu==5.5.4 packaging==25.0 diff --git a/frontend/src/app/(event)/[event-code]/page-client.tsx b/frontend/src/app/(event)/[event-code]/page-client.tsx index 431eaae12..828c8a87c 100644 --- a/frontend/src/app/(event)/[event-code]/page-client.tsx +++ b/frontend/src/app/(event)/[event-code]/page-client.tsx @@ -2,11 +2,12 @@ import { useState } from "react"; -import { PencilIcon, SquarePenIcon } from "lucide-react"; +import { PencilIcon, ShareIcon, SquarePenIcon } from "lucide-react"; import CopyToastButton from "@/components/copy-toast-button"; import KebabMenu from "@/components/kebab-menu"; import { EventInformation } from "@/core/event/types"; +import ActionButton from "@/features/button/components/action"; import LinkButton from "@/features/button/components/link"; import ScheduleGrid from "@/features/event/grid/grid"; import AttendeesPanel from "@/features/event/results/attendee-panel/panel"; @@ -19,7 +20,8 @@ import DisplaySettings from "@/features/event/results/display-settings"; import ResultsDrawer from "@/features/event/results/drawer"; import { ResultsInformation } from "@/features/event/results/lib/types"; import HeaderSpacer from "@/features/header/components/header-spacer"; -import { useHeaderSize } from "@/features/header/context"; +import { useToast } from "@/features/system-feedback"; +import { MESSAGES } from "@/lib/messages"; import { cn } from "@/lib/utils/classname"; export default function ClientPage({ @@ -36,6 +38,8 @@ export default function ClientPage({ ); } +type HeaderButtonStyle = "frosted glass inset" | "secondary"; + function EventResults({ eventData }: { eventData: EventInformation }) { const { hoveredSlot, @@ -57,6 +61,9 @@ function EventResults({ eventData }: { eventData: EventInformation }) { timeslots, } = eventData; + /* TOAST PROVIDER */ + const { addToast } = useToast(); + /* TIMEZONE HANDLING */ const handleTZChange = (newTZ: string | number) => { setTimezone(newTZ.toString()); @@ -75,9 +82,6 @@ function EventResults({ eventData }: { eventData: EventInformation }) { return drawerSnap; }; - /* HEADER SPACING */ - const { topMarginClass } = useHeaderSize(); - /* BANNERS */ const banners = getResultBanners( availabilities, @@ -97,7 +101,7 @@ function EventResults({ eventData }: { eventData: EventInformation }) { /> ); - const editButton = (buttonStyle: "frosted glass inset" | "secondary") => ( + const editButton = (buttonStyle: HeaderButtonStyle) => ( } @@ -106,12 +110,48 @@ function EventResults({ eventData }: { eventData: EventInformation }) { /> ); - const copyButton = (buttonStyle: "frosted glass inset" | "secondary") => ( + const shareButton = (buttonStyle: HeaderButtonStyle) => { + // Check if sharing is supported + if (typeof navigator !== "undefined" && !navigator.share) { + /* This condition means it will be rendered until mounted on the client, then it + * disappears if not supported. There are more browsers that support the API than + * don't, so this is a better trade-off than having the button appear after initial + * mount on supported browsers. + * + * This also won't be visible on mobile anyway, since the buttons are hidden in the + * kebab menu. + */ + return null; + } else { + return ( + } + label="Share Event" + onClick={async () => { + try { + await navigator.share({ + title: eventTitle, + url: window.location.href, + }); + } catch (error) { + // An error is thrown if sharing is cancelled, ignore that + if (error instanceof Error && error.name !== "AbortError") { + addToast("error", MESSAGES.ERROR_GENERIC); + } + } + }} + /> + ); + } + }; + + const copyButton = (buttonStyle: HeaderButtonStyle) => ( ); return ( -
+
{/* Header */} @@ -121,12 +161,14 @@ function EventResults({ eventData }: { eventData: EventInformation }) {
{isCreator && editButton("frosted glass inset")} + {shareButton("frosted glass inset")} {copyButton("frosted glass inset")}
{isCreator && editButton("secondary")} + {shareButton("secondary")} {copyButton("secondary")} {paintingButton}
@@ -134,7 +176,7 @@ function EventResults({ eventData }: { eventData: EventInformation }) {
{banners}
-
+
{banners} -
+
+
{/* Rate Limit Error */} @@ -264,14 +259,9 @@ export default function ClientPage({
{/* Main Content */} -
+
{/* Left Panel */} -
+

void; + anchorPoint?: AnchorPoint; nested?: boolean; + closeOnClick?: boolean; }) { const id = useId(); const containerRef = useRef(null); @@ -83,7 +89,12 @@ export default function KebabMenu({ key="trigger" layoutId={"popover-morph-" + id} transition={morphTransition} - className="absolute right-0 top-0 z-10 inline-block cursor-pointer rounded-full" + className={cn( + "absolute top-0 z-10 inline-block cursor-pointer rounded-full", + anchorPoint === "top-right" && "right-0", + anchorPoint === "top-left" && "left-0", + anchorPoint === "top-center" && "left-1/2 -translate-x-1/2", + )} aria-haspopup="menu" aria-expanded={open} onClick={() => handleOpenChange(true)} @@ -104,18 +115,23 @@ export default function KebabMenu({ layoutId={"popover-morph-" + id} transition={morphTransition} className={cn( - "absolute right-2 top-1 z-[100]", - "flex min-w-[200px] origin-top-right flex-col gap-2", + "absolute top-1 z-[100]", + anchorPoint === "top-right" && "right-2 origin-top-right", + anchorPoint === "top-left" && "left-2 origin-top-left", + anchorPoint === "top-center" && + "left-1/2 origin-top -translate-x-1/2", + "flex min-w-[200px] flex-col gap-2", "frosted-glass overflow-hidden rounded-3xl p-4 shadow-lg", nested && "scale-110", )} onClick={(e) => { const target = e.target as HTMLElement; if ( - target.closest("button") || - target.closest("a") || - target.closest('[role="button"]') || - target.closest('[role="menuitem"]') + (target.closest("button") || + target.closest("a") || + target.closest('[role="button"]') || + target.closest('[role="menuitem"]')) && + closeOnClick ) { handleOpenChange(false); } diff --git a/frontend/src/components/segmented-control.tsx b/frontend/src/components/segmented-control.tsx index fafa6301b..d77ff513c 100644 --- a/frontend/src/components/segmented-control.tsx +++ b/frontend/src/components/segmented-control.tsx @@ -5,7 +5,7 @@ import { useMemo } from "react"; import { cn } from "@/lib/utils/classname"; type SegmentedControlProps = { - options: { label: React.ReactNode; value: T }[]; + options: { label: React.ReactNode; value: T; ariaLabel?: string }[]; value: T; onChange: (value: T) => void; className?: string; @@ -53,6 +53,7 @@ export default function SegmentedControl({ key={option.value} type="button" onClick={() => onChange(option.value)} + aria-label={option.ariaLabel} className={cn( "z-10 flex w-full items-center justify-center rounded-full py-2 text-sm font-medium transition-colors duration-300 focus:outline-none", isSelected diff --git a/frontend/src/features/drawer/components/base.tsx b/frontend/src/features/drawer/components/base.tsx index 7f1d0650f..391cf03cc 100644 --- a/frontend/src/features/drawer/components/base.tsx +++ b/frontend/src/features/drawer/components/base.tsx @@ -146,7 +146,7 @@ export default function BaseDrawer({ style={{ zIndex: contentZIndex }} >

- {(!isPill || _type !== "morphing") && children} + {/* Wrap in an extra div to manage the opacity transition separately */} +
+ {(!isPill || _type !== "morphing") && children} +
{footerContent && ( diff --git a/frontend/src/features/event/grid/grid.tsx b/frontend/src/features/event/grid/grid.tsx index 5971cad7f..ee595f66f 100644 --- a/frontend/src/features/event/grid/grid.tsx +++ b/frontend/src/features/event/grid/grid.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { TriangleAlertIcon } from "lucide-react"; @@ -14,6 +14,7 @@ import TimeColumn from "@/features/event/grid/time-column"; import InteractiveTimeBlock from "@/features/event/grid/timeblocks/interactive"; import PreviewTimeBlock from "@/features/event/grid/timeblocks/preview"; import ResultsTimeBlock from "@/features/event/grid/timeblocks/results"; +import { getHighestMatchCount } from "@/features/event/results/lib/utils"; import useCheckMobile from "@/lib/hooks/use-check-mobile"; import { cn } from "@/lib/utils/classname"; @@ -94,6 +95,23 @@ export default function ScheduleGrid({ const hasPrevPage = currentPage > 0; const hasNextPage = currentPage < totalPages - 1; + // Check if the scrollbar is present to pass to the header + const [scrollbarPresent, setScrollbarPresent] = useState(false); + const scrollRef = useRef(null); + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + + const checkScrollbar = () => + // Include mobile check because it will report true even if the scrollbar is hidden + setScrollbarPresent(el.scrollHeight > el.clientHeight && !isMobile); + const resizeObserver = new ResizeObserver(checkScrollbar); + resizeObserver.observe(el); + checkScrollbar(); + + return () => resizeObserver.disconnect(); + }); + if (error) return ; return ( @@ -108,6 +126,7 @@ export default function ScheduleGrid({ visibleDays={visibleDays} currentPage={currentPage} totalPages={totalPages} + scrollbarPresent={scrollbarPresent} isWeekdayEvent={isWeekdayEvent} onPrevPage={() => paginate(-1)} onNextPage={() => paginate(1)} @@ -115,9 +134,11 @@ export default function ScheduleGrid({ />
@@ -171,6 +192,7 @@ export default function ScheduleGrid({ hoveredSlot={hoveredSlot} availabilities={availabilities} numParticipants={numParticipants} + highestMatchCount={getHighestMatchCount(availabilities)} onHoverSlot={setHoveredSlot} /> ); diff --git a/frontend/src/features/event/grid/schedule-header.tsx b/frontend/src/features/event/grid/schedule-header.tsx index 61ccb0e0d..5fa0c520e 100644 --- a/frontend/src/features/event/grid/schedule-header.tsx +++ b/frontend/src/features/event/grid/schedule-header.tsx @@ -16,6 +16,7 @@ interface ScheduleHeaderProps { visibleDays: { dayKey: string; dayDisplay: string }[]; currentPage: number; totalPages: number; + scrollbarPresent?: boolean; isWeekdayEvent?: boolean; onPrevPage: () => void; onNextPage: () => void; @@ -42,6 +43,7 @@ export default function ScheduleHeader({ visibleDays, currentPage, totalPages, + scrollbarPresent = false, isWeekdayEvent = false, onPrevPage, onNextPage, @@ -52,7 +54,8 @@ export default function ScheduleHeader({ return (
, + { + className: cn( + (icon as React.ReactElement<{ className: string }>).props.className, + "h-4 w-4", + ), + }, + ); + } + return (
+ > + {icon && ( +
{icon}
+ )} +
); } diff --git a/frontend/src/features/event/grid/timeblocks/props.ts b/frontend/src/features/event/grid/timeblocks/props.ts index d0d10c176..b0f63fb7a 100644 --- a/frontend/src/features/event/grid/timeblocks/props.ts +++ b/frontend/src/features/event/grid/timeblocks/props.ts @@ -37,5 +37,6 @@ export type ResultsTimeBlockProps = CommonBlockProps & { hoveredSlot: string | null | undefined; availabilities: ResultsAvailabilityMap; numParticipants: number; + highestMatchCount: number; onHoverSlot?: (iso: string | null) => void; }; diff --git a/frontend/src/features/event/grid/timeblocks/results.tsx b/frontend/src/features/event/grid/timeblocks/results.tsx index cc9c7b0e0..a4f3eb4ab 100644 --- a/frontend/src/features/event/grid/timeblocks/results.tsx +++ b/frontend/src/features/event/grid/timeblocks/results.tsx @@ -1,3 +1,5 @@ +import { CircleSmallIcon, ThumbsUpIcon } from "lucide-react"; + import TimeSlot from "@/features/event/grid/time-slot"; import BaseTimeBlock from "@/features/event/grid/timeblocks/base"; import { ResultsTimeBlockProps } from "@/features/event/grid/timeblocks/props"; @@ -8,6 +10,7 @@ export default function ResultsTimeBlock({ numVisibleDays, availabilities, numParticipants, + highestMatchCount, hoveredSlot, onHoverSlot, hasNext = false, @@ -41,6 +44,18 @@ export default function ResultsTimeBlock({ "bg-[color-mix(in_srgb,var(--color-accent)_var(--opacity-percent),var(--color-background))]", ); + // icon + const iconColorClass = + opacityPercent > 50 ? "text-white" : "text-foreground"; + const icon = + highestMatchCount > 1 && matchCount === highestMatchCount ? ( + highestMatchCount === numParticipants ? ( + + ) : ( + + ) + ) : undefined; + return ( { onHoverSlot?.(iso); }} diff --git a/frontend/src/features/event/results/attendee-panel/panel-header.tsx b/frontend/src/features/event/results/attendee-panel/panel-header.tsx index ebf26fc35..1123fe19b 100644 --- a/frontend/src/features/event/results/attendee-panel/panel-header.tsx +++ b/frontend/src/features/event/results/attendee-panel/panel-header.tsx @@ -1,4 +1,4 @@ -import { CheckIcon, EraserIcon, LogOutIcon, Undo2Icon } from "lucide-react"; +import { CheckIcon, DoorOpenIcon, EraserIcon, Undo2Icon } from "lucide-react"; import ActionButton from "@/features/button/components/action"; import { useResultsContext } from "@/features/event/results/context"; @@ -125,11 +125,11 @@ export default function PanelHeader({ {showSelfRemove && ( } + icon={} onClick={() => { promptRemove(currentUser); }} - aria-label="Remove Self from Event" + aria-label="Leave Event" className="hover:bg-error/25 hover:text-error active:bg-error/40 shrink-0" /> )} diff --git a/frontend/src/features/event/results/attendee-panel/panel.tsx b/frontend/src/features/event/results/attendee-panel/panel.tsx index 592d46d20..5e3c6a9ca 100644 --- a/frontend/src/features/event/results/attendee-panel/panel.tsx +++ b/frontend/src/features/event/results/attendee-panel/panel.tsx @@ -58,13 +58,11 @@ export default function AttendeesPanel() { type="delete" autoClose={true} title={ - personToRemove === currentUser - ? "Remove Yourself" - : "Remove Participant" + personToRemove === currentUser ? "Leave Event" : "Remove Participant" } description={ - personToRemove == currentUser ? ( - "Are you sure you want to remove yourself from this event?" + personToRemove === currentUser ? ( + "Are you sure you want to leave this event?" ) : ( Are you sure you want to remove{" "} diff --git a/frontend/src/features/event/results/attendee-panel/participant-chip.tsx b/frontend/src/features/event/results/attendee-panel/participant-chip.tsx index 4ccf4be3e..04ca6137e 100644 --- a/frontend/src/features/event/results/attendee-panel/participant-chip.tsx +++ b/frontend/src/features/event/results/attendee-panel/participant-chip.tsx @@ -52,15 +52,19 @@ export default function ParticipantChip({ // Availability Styling !isAvailable && "bg-gray-200/25 line-through opacity-50", - isAvailable && "bg-accent/25 text-accent-text opacity-100", + isAvailable && "bg-lion text-violet opacity-100", + + !isAvailable && isSelected && "text-violet", // Selection Styling isSelected && - "bg-accent ring-accent ring-offset-background text-white ring-2 ring-offset-1", - areSelected && !isSelected && "bg-gray-200/25", + "bg-lion ring-lion ring-offset-background ring-2 ring-offset-1", + areSelected && !isSelected && "text-foreground bg-gray-200/25", // Hover Styling - !isRemoving && !isSelected && "hover:bg-accent hover:text-white", + !isRemoving && + !isSelected && + "hover:ring-lion/50 hover:ring-offset-background hover:ring-2 hover:ring-offset-1", // Wiggle/Remove Styling isRemoving && diff --git a/frontend/src/features/event/results/banners.tsx b/frontend/src/features/event/results/banners.tsx index 34c8c4c53..b3e5908aa 100644 --- a/frontend/src/features/event/results/banners.tsx +++ b/frontend/src/features/event/results/banners.tsx @@ -1,5 +1,8 @@ import { ResultsAvailabilityMap } from "@/core/availability/types"; -import { hasMutualAvailability } from "@/features/event/results/lib/utils"; +import { + getHighestMatchCount, + hasMutualAvailability, +} from "@/features/event/results/lib/utils"; import { Banner } from "@/features/system-feedback/banner/base"; import { MESSAGES } from "@/lib/messages"; @@ -30,9 +33,16 @@ export function getResultBanners( ); } else if (!hasMutualAvailability(availabilities, participants)) { + if (getHighestMatchCount(availabilities) <= 1) { + return ( + +

{MESSAGES.INFO_NO_MUTUAL_AVAILABILITY}

+
+ ); + } return ( -

{MESSAGES.INFO_NO_MUTUAL_AVAILABILITY}

+

{MESSAGES.INFO_NO_IDEAL_TIMES_BANNER}

); } else if (participated && participants.length === 1) { diff --git a/frontend/src/features/event/results/drawer.tsx b/frontend/src/features/event/results/drawer.tsx index 4adc81ce7..a9810a01e 100644 --- a/frontend/src/features/event/results/drawer.tsx +++ b/frontend/src/features/event/results/drawer.tsx @@ -131,13 +131,11 @@ export default function ResultsDrawer({ type="delete" autoClose={true} title={ - personToRemove === currentUser - ? "Remove Yourself" - : "Remove Participant" + personToRemove === currentUser ? "Leave Event" : "Remove Participant" } description={ personToRemove == currentUser ? ( - "Are you sure you want to remove yourself from this event?" + "Are you sure you want to leave this event?" ) : ( Are you sure you want to remove{" "} diff --git a/frontend/src/features/event/results/lib/utils.ts b/frontend/src/features/event/results/lib/utils.ts index ad33d9d4b..1b915e1c1 100644 --- a/frontend/src/features/event/results/lib/utils.ts +++ b/frontend/src/features/event/results/lib/utils.ts @@ -51,3 +51,19 @@ export function findConsensusAndConflicts( return { allAvailableSlots, noOneAvailableSlots }; } + +/** + * Finds the highest number of participants available for any single timeslot. + * + * @returns The maximum count of participants available in any timeslot. + */ +export function getHighestMatchCount(availabilities: ResultsAvailabilityMap): number { + let highestCount = 0; + for (const slot in availabilities) { + const count = availabilities[slot].length; + if (count > highestCount) { + highestCount = count; + } + } + return highestCount; +} diff --git a/frontend/src/features/header/components/header.tsx b/frontend/src/features/header/components/header.tsx index 1aad1b1cc..cf6ee7161 100644 --- a/frontend/src/features/header/components/header.tsx +++ b/frontend/src/features/header/components/header.tsx @@ -8,7 +8,7 @@ import AccountButton from "@/features/header/components/account-button"; import DashboardButton from "@/features/header/components/dashboard-button"; import LogoArea from "@/features/header/components/logo-area"; import NewEventButton from "@/features/header/components/new-event-button"; -import ThemeToggle from "@/features/header/components/theme-toggle"; +import ThemePicker from "@/features/header/components/theme-picker"; import { useHeaderSize } from "@/features/header/context"; import useCheckMobile from "@/lib/hooks/use-check-mobile"; import { cn } from "@/lib/utils/classname"; @@ -107,7 +107,7 @@ export default function Header() { /> - + diff --git a/frontend/src/features/header/components/theme-picker.tsx b/frontend/src/features/header/components/theme-picker.tsx new file mode 100644 index 000000000..8a82b8435 --- /dev/null +++ b/frontend/src/features/header/components/theme-picker.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { MonitorIcon, MoonIcon, SunIcon, SunMoonIcon } from "lucide-react"; +import { useTheme } from "next-themes"; + +import KebabMenu from "@/components/kebab-menu"; +import SegmentedControl from "@/components/segmented-control"; +import EmptyButton from "@/features/button/components/empty"; +import ShrinkingHeaderButton from "@/features/header/components/shrinking-header-button"; +import { useHeaderSize } from "@/features/header/context"; + +export default function ThemePicker() { + const { activeMenu, setActiveMenu } = useHeaderSize(); + const { theme = "system", setTheme } = useTheme(); + + const isMenuOpen = activeMenu === "theme"; + + return ( + } + > + setActiveMenu(isOpen ? "theme" : null)} + anchorPoint="top-center" + trigger={ + } + aria-label="Choose Site Theme" + /> + } + closeOnClick={false} + > +
Theme
+ , + ariaLabel: "Match System Theme", + }, + { value: "light", label: , ariaLabel: "Light Theme" }, + { value: "dark", label: , ariaLabel: "Dark Theme" }, + ]} + value={theme} + onChange={setTheme} + className="frosted-glass-inset" + /> +
+ {theme === "system" + ? "Match System" + : theme === "light" + ? "Light" + : "Dark"} +
+
+
+ ); +} diff --git a/frontend/src/features/header/components/theme-toggle.tsx b/frontend/src/features/header/components/theme-toggle.tsx deleted file mode 100644 index 113bda349..000000000 --- a/frontend/src/features/header/components/theme-toggle.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -import { MoonIcon, SunIcon } from "lucide-react"; -import { useTheme } from "next-themes"; - -import ActionButton from "@/features/button/components/action"; -import ShrinkingHeaderButton from "@/features/header/components/shrinking-header-button"; - -export default function ThemeToggle() { - const { setTheme, resolvedTheme } = useTheme(); - - const toggleTheme = () => { - setTheme(resolvedTheme === "dark" ? "light" : "dark"); - }; - - return ( - : } - > - : } - onClick={toggleTheme} - /> - - ); -} diff --git a/frontend/src/features/version-history/data.ts b/frontend/src/features/version-history/data.ts index adf4cf0b7..350c33307 100644 --- a/frontend/src/features/version-history/data.ts +++ b/frontend/src/features/version-history/data.ts @@ -35,7 +35,7 @@ export function getVersionHistoryData(): VersionHistoryData { "Added a version history page", "Updated error handling across the site", "Updated certain parts of the event editor", - 'Improved readability of transparent components', + "Improved readability of transparent components", "Temporarily disabled weekday events for fixes", ], bugFixes: ["Fixed the theme transition on the landing page"], @@ -131,7 +131,7 @@ export function getVersionHistoryData(): VersionHistoryData { "Updated the event results page to reduce clutter", "Updated the appearance of events on the dashboard", "Updated the event grid to list the ending hour on the left axis", - "Removed \"Intended Duration\" from events", + 'Removed "Intended Duration" from events', ], bugFixes: [ "Fixed an issue where an invalid custom event code would not show an error", @@ -159,8 +159,21 @@ export function getVersionHistoryData(): VersionHistoryData { "Fixed the attendee count display when there is only one participant", ], }, + { + version: "v0.4.3", + releaseDate: { year: 2026, month: 4, day: 9 }, + changes: [ + "Added icons to the results grid to indicate the best times", + "Added a share button to the results page", + 'Added a new theme picker, which includes a "Match System" option', + 'Updated the "Leave Event" button icon', + "Adjusted the scroll functionality of the event grid", + "Changed the color of event attendees on the results page", + "Fixed the drawer transition on the mobile results page", + ], + }, ], - } + }, ]; } diff --git a/frontend/src/lib/messages.ts b/frontend/src/lib/messages.ts index b13e6a154..4d08f4ff9 100644 --- a/frontend/src/lib/messages.ts +++ b/frontend/src/lib/messages.ts @@ -58,5 +58,7 @@ export const MESSAGES = { INFO_ADD_AVAILABILITY: "Add your availability by clicking the button above.", INFO_ADD_AVAILABILITY_MOBILE: "Add your availability by clicking the button below.", INFO_COPY_SHARE_LINK: "Copy and share the link so others can join!", - INFO_NO_MUTUAL_AVAILABILITY: "There is no time where everyone is available.", + INFO_NO_MUTUAL_AVAILABILITY: "There are no times with more than 1 person available. The plans are NOT making it out of the group chat...", + INFO_NO_IDEAL_TIMES: "There are no times where everyone is available.", + INFO_NO_IDEAL_TIMES_BANNER: "There are no times where everyone is available. Times with an indicator are the best options.", };