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.",
};