diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index e08d97bf3..47987931e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -15,6 +15,7 @@
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.4",
+ "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-toast": "^1.2.15",
"@vercel/analytics": "^2.0.1",
@@ -2725,6 +2726,112 @@
}
}
},
+ "node_modules/@radix-ui/react-slider": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
+ "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-collection": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
+ "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-slot": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz",
@@ -3506,6 +3613,7 @@
"version": "19.1.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz",
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
+ "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -3515,6 +3623,7 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz",
"integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==",
"devOptional": true,
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@@ -3561,6 +3670,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.31.1.tgz",
"integrity": "sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==",
"dev": true,
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.31.1",
"@typescript-eslint/types": "8.31.1",
@@ -4033,6 +4143,7 @@
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4573,6 +4684,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@@ -4896,6 +5008,7 @@
"version": "9.36.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz",
"integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5082,6 +5195,7 @@
"version": "2.32.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -6810,6 +6924,7 @@
"resolved": "https://registry.npmjs.org/next/-/next-15.4.10.tgz",
"integrity": "sha512-itVlc79QjpKMFMRhP+kbGKaSG/gZM6RCvwhEbwmCNF06CdDiNaoHcbeg0PqkEa2GOcn8KJ0nnc7+yL7EjoYLHQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@next/env": "15.4.10",
"@swc/helpers": "0.5.15",
@@ -7261,6 +7376,7 @@
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -7417,6 +7533,7 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7451,6 +7568,7 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
+ "peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -7512,6 +7630,7 @@
"version": "5.10.2",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
"integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.12.0",
"@emotion/cache": "^11.4.0",
@@ -8244,6 +8363,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -8397,6 +8517,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
diff --git a/frontend/package.json b/frontend/package.json
index 2b46d32c3..7ad439168 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -19,6 +19,7 @@
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.4",
+ "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-toast": "^1.2.15",
"@vercel/analytics": "^2.0.1",
diff --git a/frontend/src/app/(event)/[event-code]/page-client.tsx b/frontend/src/app/(event)/[event-code]/page-client.tsx
index a9bbf1362..678ecf05a 100644
--- a/frontend/src/app/(event)/[event-code]/page-client.tsx
+++ b/frontend/src/app/(event)/[event-code]/page-client.tsx
@@ -2,17 +2,19 @@
import { useState } from "react";
+import { AnimatePresence, motion } from "framer-motion";
import { PencilIcon, ShareIcon, SquarePenIcon } from "lucide-react";
import KebabMenu from "@/components/kebab-menu";
import { EventInformation } from "@/core/event/types";
import EmptyButton from "@/features/button/components/empty";
import LinkButton from "@/features/button/components/link";
+import TimeZoneSelector from "@/features/event/components/selectors/timezone";
import ScheduleGrid from "@/features/event/grid/grid";
import AttendeesPanel from "@/features/event/results/attendees/desktop-panel";
import AttendeesDrawer from "@/features/event/results/attendees/mobile-drawer";
+import AvailabilityFilters from "@/features/event/results/components/availability-filters";
import { getResultBanners } from "@/features/event/results/components/banners";
-import DisplaySettings from "@/features/event/results/components/display-settings";
import {
ResultsProvider,
useResultsContext,
@@ -86,6 +88,34 @@ function EventResults({ eventData }: { eventData: EventInformation }) {
currentUser !== null,
);
+ /* DISPLAY SETTINGS */
+ const renderTimezoneSelector = (id: string) => (
+
+ Displaying event in
+
+
+ );
+
+ const availabilityFilters = (
+
+
+
+ );
+
/* BUTTONS */
const paintingButton = (
-
-
+
+ {renderTimezoneSelector("timezone-select-mobile")}
{/* Mobile Spacer & Drawer */}
@@ -171,6 +198,7 @@ function EventResults({ eventData }: { eventData: EventInformation }) {
onSnapChange={setDrawerSnap}
eventTitle={eventTitle}
eventCode={eventCode}
+ numParticipants={participants.length}
/>
@@ -185,12 +213,10 @@ function EventResults({ eventData }: { eventData: EventInformation }) {
{banners}
-
-
-
+
+ {participants.length > 1 && availabilityFilters}
+
+ {renderTimezoneSelector("timezone-select-desktop")}
diff --git a/frontend/src/features/event/results/attendees/mobile-drawer.tsx b/frontend/src/features/event/results/attendees/mobile-drawer.tsx
index beb337dc3..bd1ea0cc8 100644
--- a/frontend/src/features/event/results/attendees/mobile-drawer.tsx
+++ b/frontend/src/features/event/results/attendees/mobile-drawer.tsx
@@ -1,26 +1,29 @@
import { useEffect, useState } from "react";
-import { ShareIcon, SquarePenIcon } from "lucide-react";
+import { ShareIcon, SlidersHorizontalIcon, SquarePenIcon } from "lucide-react";
import EmptyButton from "@/features/button/components/empty";
import LinkButton from "@/features/button/components/link";
-import { MorphingDrawer } from "@/features/drawer";
+import { MorphingDrawer, FloatingDrawer } from "@/features/drawer";
import PanelHeader from "@/features/event/results/attendees/panel-header";
import ParticipantList from "@/features/event/results/attendees/participant-list";
import {
RemoveParticipantDialog,
useParticipantRemoval,
} from "@/features/event/results/attendees/remove-participant";
+import AvailabilityFilters from "@/features/event/results/components/availability-filters";
import ShareMenu from "@/features/share-menu/menu";
export default function AttendeesDrawer({
onSnapChange,
eventTitle,
eventCode,
+ numParticipants,
}: {
onSnapChange: (snap: number | string | null) => void;
eventTitle: string;
eventCode: string;
+ numParticipants: number;
}) {
const {
isRemoving,
@@ -37,6 +40,7 @@ export default function AttendeesDrawer({
/* TABS */
const [activeSnap, setActiveSnap] = useState(0.22);
+ const [openViewOptions, setOpenViewOptions] = useState(false);
useEffect(() => {
onSnapChange(activeSnap);
@@ -61,6 +65,41 @@ export default function AttendeesDrawer({
/>
);
+ const filtersButton = (
+ }
+ aria-label="View Options"
+ />
+ }
+ >
+
+
+ );
+
+ const shareButton = (
+ }
+ aria-label="Share Event"
+ />
+ }
+ eventTitle={eventTitle}
+ eventCode={eventCode}
+ isNested
+ />
+ );
+
return (
- }
- aria-label="Share Event"
- />
- }
- eventTitle={eventTitle}
- eventCode={eventCode}
- isNested
- />
+
+ {shareButton}
+ {numParticipants > 1 && filtersButton}
+
{paintingButton}
}
diff --git a/frontend/src/features/event/results/attendees/participant-chip.tsx b/frontend/src/features/event/results/attendees/participant-chip.tsx
index 04ca6137e..6398fd2a4 100644
--- a/frontend/src/features/event/results/attendees/participant-chip.tsx
+++ b/frontend/src/features/event/results/attendees/participant-chip.tsx
@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils/classname";
export default function ParticipantChip({
person,
index,
+ includedInSlider,
isAvailable,
isRemoving,
onRemove,
@@ -15,6 +16,7 @@ export default function ParticipantChip({
}: {
person: string;
index: number;
+ includedInSlider: boolean;
isAvailable: boolean;
isRemoving: boolean;
onRemove: () => void;
@@ -30,7 +32,9 @@ export default function ParticipantChip({
{
- if (window.matchMedia("(hover: hover)").matches) {
+ if (window.matchMedia("(hover: hover)").matches && includedInSlider) {
+ // Only trigger hover effects on devices that support hover and if the
+ // participant is included in the slider
onHoverChange(true);
}
}}
@@ -38,7 +42,10 @@ export default function ParticipantChip({
onClick={() => {
if (isRemoving) {
onRemove();
- } else {
+ } else if (includedInSlider || isSelected) {
+ // Only allow clicking if included in the slider OR already selected
+ // (The already selected check allows them to un-click themselves if state
+ // got weird)
onHoverChange(false);
onClick();
}
@@ -56,6 +63,15 @@ export default function ParticipantChip({
!isAvailable && isSelected && "text-violet",
+ // Slider Styling
+ // Participants are only disabled when they are both excluded from the slider filters
+ // and not in removing mode.
+ !includedInSlider &&
+ !isRemoving &&
+ (isSelected
+ ? "text-foreground bg-gray-200/25 opacity-50"
+ : "text-foreground pointer-events-none bg-gray-200/25 opacity-50"),
+
// Selection Styling
isSelected &&
"bg-lion ring-lion ring-offset-background ring-2 ring-offset-1",
diff --git a/frontend/src/features/event/results/attendees/participant-list.tsx b/frontend/src/features/event/results/attendees/participant-list.tsx
index 11b0bed87..189a6ecf9 100644
--- a/frontend/src/features/event/results/attendees/participant-list.tsx
+++ b/frontend/src/features/event/results/attendees/participant-list.tsx
@@ -18,6 +18,7 @@ export default function ParticipantList({
isCreator,
setHoveredParticipant,
toggleParticipant: onParticipantToggle,
+ validParticipantsForList,
} = useResultsContext();
const listClassNames = mobile
@@ -39,6 +40,7 @@ export default function ParticipantList({
key={person}
index={index}
person={person}
+ includedInSlider={validParticipantsForList.includes(person)}
isAvailable={
!hoveredSlot || availabilities[hoveredSlot]?.includes(person)
}
diff --git a/frontend/src/features/event/results/components/availability-filters.tsx b/frontend/src/features/event/results/components/availability-filters.tsx
new file mode 100644
index 000000000..15cd95bf7
--- /dev/null
+++ b/frontend/src/features/event/results/components/availability-filters.tsx
@@ -0,0 +1,175 @@
+import { useEffect, useMemo } from "react";
+
+import * as Slider from "@radix-ui/react-slider";
+import { AnimatePresence, motion, useAnimation } from "framer-motion";
+
+import Checkbox from "@/components/checkbox";
+import { useResultsContext } from "@/features/event/results/context";
+import { Banner } from "@/features/system-feedback/banner/base";
+import { cn } from "@/lib/utils/classname";
+
+export default function AvailabilityFilters() {
+ const controls = useAnimation();
+ const {
+ participants,
+ minAvailability,
+ setMinAvailability,
+ showOnlyBestTimes,
+ setShowOnlyBestTimes,
+ filteredAvailabilities,
+ } = useResultsContext();
+
+ const max = participants.length;
+ const hasNoAvailability = Object.keys(filteredAvailabilities).length === 0;
+
+ const displayedAvailability = showOnlyBestTimes
+ ? max
+ : Math.min(minAvailability, max);
+
+ const handleCheckboxChange = (checked: boolean) => {
+ setShowOnlyBestTimes(checked);
+
+ if (checked) {
+ setMinAvailability(max);
+ } else {
+ setMinAvailability(1);
+ }
+ };
+
+ const ticks = useMemo(() => {
+ const indices = [];
+ for (let i = 0; i < max; i++) {
+ const value = i + 1;
+ const isEndpoint = i === 0 || i === max - 1;
+ const isMajorTick = value % 10 === 0;
+ const isMinorTick = value % 5 === 0;
+
+ if (max < 50 || isEndpoint || isMajorTick || isMinorTick) {
+ indices.push(i);
+ }
+ }
+ return indices;
+ }, [max]);
+
+ useEffect(() => {
+ controls.start({
+ scale: [1, 1.3, 1],
+ transition: { duration: 0.2, ease: "easeOut" },
+ });
+ }, [displayedAvailability, controls]);
+
+ return (
+
+
+ {hasNoAvailability && (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
setMinAvailability(value[0])}
+ >
+
+
+
+
+
+ {ticks.map((i) => {
+ const value = i + 1;
+ const leftPercentage = max <= 1 ? 0 : (i / (max - 1)) * 100;
+
+ return (
+
+
+
+ {(i === 0 || i === max - 1) && (
+
+ {value}
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/event/results/components/banners.tsx b/frontend/src/features/event/results/components/banners.tsx
index b3e5908aa..7ee3c9ee8 100644
--- a/frontend/src/features/event/results/components/banners.tsx
+++ b/frontend/src/features/event/results/components/banners.tsx
@@ -32,17 +32,17 @@ export function getResultBanners(
{MESSAGES.INFO_ADD_AVAILABILITY}
);
- } else if (!hasMutualAvailability(availabilities, participants)) {
+ } else if (!hasMutualAvailability(availabilities, participants.length)) {
if (getHighestMatchCount(availabilities) <= 1) {
return (
- {MESSAGES.INFO_NO_MUTUAL_AVAILABILITY}
+ {MESSAGES.INFO_NO_OVERLAP}
);
}
return (
- {MESSAGES.INFO_NO_IDEAL_TIMES_BANNER}
+ {MESSAGES.INFO_NO_IDEAL_TIMES}
);
} else if (participated && participants.length === 1) {
diff --git a/frontend/src/features/event/results/components/display-settings.tsx b/frontend/src/features/event/results/components/display-settings.tsx
deleted file mode 100644
index 4fc9a6df0..000000000
--- a/frontend/src/features/event/results/components/display-settings.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-// import Checkbox from "@/components/checkbox";
-import TimeZoneSelector from "@/features/event/components/selectors/timezone";
-// import { useResultsContext } from "@/features/event/results/context";
-
-export default function DisplaySettings({
- timezone,
- onTimezoneChange,
- drawerNesting = 0,
-}: {
- timezone: string;
- onTimezoneChange: (newTZ: string) => void;
- drawerNesting?: number;
-}) {
- // const { showOnlyBestTimes, setShowOnlyBestTimes } = useResultsContext();
-
- return (
- <>
- {/* */}
- {/* */}
- Displaying event in
-
- {/*
*/}
- >
- );
-}
diff --git a/frontend/src/features/event/results/lib/use-results.ts b/frontend/src/features/event/results/lib/use-results.ts
index b5dbc2f7e..32fbce040 100644
--- a/frontend/src/features/event/results/lib/use-results.ts
+++ b/frontend/src/features/event/results/lib/use-results.ts
@@ -12,11 +12,9 @@ import { removePerson } from "@/features/event/results/lib/remove-person";
import { ResultsInformation } from "@/features/event/results/lib/types";
import { findConsensusAndConflicts } from "@/features/event/results/lib/utils";
import { useToast } from "@/features/system-feedback/toast/context";
-import { MESSAGES } from "@/lib/messages";
export function useEventResults(initialData: ResultsInformation) {
const { addToast } = useToast();
-
const { eventCode, isCreator, participants, availability, currentUser } =
initialData;
@@ -29,17 +27,17 @@ export function useEventResults(initialData: ResultsInformation) {
);
const [hoveredSlot, setHoveredSlot] = useState(null);
const [showOnlyBestTimes, setShowOnlyBestTimes] = useState(false);
+ const [minAvailability, setMinAvailability] = useState(1);
+
const [timezone, setTimezone] = useState(
- // Lazy initialization to avoid the lookup on every render
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
);
/* OPTIMISTIC STATES */
const [optimisticParticipants, removeOptimisticParticipant] = useOptimistic(
participants || [],
- (state, personToRemove: string) => {
- return state.filter((p) => p !== personToRemove);
- },
+ (state, personToRemove: string) =>
+ state.filter((p) => p !== personToRemove),
);
const [optimisticAvailabilities, updateOptimisticAvailabilities] =
@@ -54,9 +52,7 @@ export function useEventResults(initialData: ResultsInformation) {
/* ACTIONS */
const handleSetHoveredParticipant = useCallback((person: string | null) => {
setHoveredParticipant(person);
- if (person) {
- setHoveredSlot(null);
- }
+ if (person) setHoveredSlot(null);
}, []);
const toggleParticipant = (person: string) => {
@@ -70,7 +66,6 @@ export function useEventResults(initialData: ResultsInformation) {
const handleRemoveParticipant = async (person: string) => {
const isRemovingSelf = currentUser === person;
- // Immediate UI update
if (selectedParticipants.includes(person)) {
setSelectedParticipants((prev) => prev.filter((p) => p !== person));
}
@@ -80,88 +75,117 @@ export function useEventResults(initialData: ResultsInformation) {
updateOptimisticAvailabilities(person);
});
- // Server Action
const result = await removePerson(eventCode, person, isCreator);
-
if (!result.success) {
addToast("error", result.error || "Error removing participant");
} else {
addToast(
"success",
- isRemovingSelf
- ? "You have been removed from the event."
- : `${person} has been removed from the event.`,
+ isRemovingSelf ? "You have been removed." : `${person} removed.`,
);
}
-
return result.success;
};
- /* DERIVED LOGIC */
- const { filteredAvailabilities, gridNumParticipants, hasNoConsensus } =
- useMemo(() => {
- if (showOnlyBestTimes) {
- const { allAvailableSlots } = findConsensusAndConflicts(
- optimisticAvailabilities,
- optimisticParticipants,
- );
-
- const noConsensus = allAvailableSlots.length === 0;
-
- const filtered: ResultsAvailabilityMap = {};
- for (const slot of allAvailableSlots) {
- filtered[slot] = optimisticAvailabilities[slot];
- }
-
- return {
- filteredAvailabilities: filtered,
- gridNumParticipants: optimisticParticipants.length,
- hasNoConsensus: noConsensus,
- };
- }
+ /* FILTERING LOGIC */
- let activeParticipants: string[] = [];
-
- if (selectedParticipants.length > 0) {
- activeParticipants = selectedParticipants;
- } else if (hoveredParticipant) {
- activeParticipants = [hoveredParticipant];
- } else {
- return {
- filteredAvailabilities: optimisticAvailabilities,
- gridNumParticipants: optimisticParticipants.length,
- };
- }
+ /**
+ * Returns a cache of the best timeslots where all participants are available.
+ * Since this can be an expensive computation, it is memoized and only recalculated
+ * when availabilities or participants change
+ */
+ const bestTimesCache = useMemo(() => {
+ const { allAvailableSlots } = findConsensusAndConflicts(
+ optimisticAvailabilities,
+ optimisticParticipants.length,
+ );
+ return {
+ slotsSet: new Set(allAvailableSlots),
+ };
+ }, [optimisticAvailabilities, optimisticParticipants]);
- const filtered: ResultsAvailabilityMap = {};
- for (const slot in optimisticAvailabilities) {
- const availablePeople = optimisticAvailabilities[slot];
- const intersection = availablePeople.filter((p) =>
- activeParticipants.includes(p),
- );
- if (intersection.length > 0) {
- filtered[slot] = intersection;
- }
- }
+ const { globalFilteredMap, validParticipantsForList } = useMemo(() => {
+ const map: ResultsAvailabilityMap = {};
+ const validSet = new Set();
+
+ for (const [slot, availablePeople] of Object.entries(
+ optimisticAvailabilities,
+ )) {
+ if (availablePeople.length < minAvailability) continue;
+ if (showOnlyBestTimes && !bestTimesCache.slotsSet.has(slot)) continue;
+ map[slot] = availablePeople;
+ availablePeople.forEach((p) => validSet.add(p));
+ }
+
+ return {
+ globalFilteredMap: map,
+ validParticipantsForList: Array.from(validSet),
+ };
+ }, [
+ optimisticAvailabilities,
+ minAvailability,
+ showOnlyBestTimes,
+ bestTimesCache,
+ ]);
+
+ /**
+ * This is the final filtered availabilities map that is used for rendering the
+ * grid. It applies the following filters before rendering:
+ * - slider filter (based on min availability)
+ * - best times filter (if showOnlyBestTimes is true)
+ *
+ * @returns The filtered availabilities map and the list of valid participants to
+ * show in the participant list.
+ */
+ const { filteredAvailabilities, gridNumParticipants } = useMemo(() => {
+ const hasSelections = selectedParticipants.length > 0;
+ const isHovering = !hasSelections && hoveredParticipant !== null;
+
+ const active = hasSelections
+ ? selectedParticipants
+ : isHovering
+ ? [hoveredParticipant]
+ : [];
+
+ const targetSet = new Set(active);
+ const isFiltering = active.length > 0;
+
+ const sourceMap = globalFilteredMap;
+
+ if (!isFiltering) {
return {
- filteredAvailabilities: filtered,
- gridNumParticipants: activeParticipants.length,
- hasNoConsensus: false,
+ filteredAvailabilities: sourceMap,
+ gridNumParticipants: optimisticParticipants.length,
};
- }, [
- showOnlyBestTimes,
- optimisticAvailabilities,
- optimisticParticipants,
- selectedParticipants,
- hoveredParticipant,
- ]);
+ }
- useEffect(() => {
- if (showOnlyBestTimes && hasNoConsensus) {
- addToast("info", MESSAGES.INFO_NO_MUTUAL_AVAILABILITY);
+ const finalMap: ResultsAvailabilityMap = {};
+ for (const [slot, availablePeople] of Object.entries(sourceMap)) {
+ const relevantPeople = availablePeople.filter((p) => targetSet.has(p));
+ if (relevantPeople.length > 0) finalMap[slot] = relevantPeople;
}
- }, [hasNoConsensus, showOnlyBestTimes, addToast]);
+
+ return {
+ filteredAvailabilities: finalMap,
+ gridNumParticipants: active.length,
+ };
+ }, [
+ selectedParticipants,
+ hoveredParticipant,
+ globalFilteredMap,
+ optimisticParticipants.length,
+ ]);
+
+ // Clamp min availability to never be higher than the total participants.
+ // Keep it within the slider's [1..N] bounds, even if the list becomes empty.
+ useEffect(() => {
+ setMinAvailability((prev) => {
+ const max = optimisticParticipants.length;
+ if (max === 0) return 1;
+ return Math.min(Math.max(prev, 1), max);
+ });
+ }, [optimisticParticipants.length]);
return {
// Data
@@ -170,6 +194,7 @@ export function useEventResults(initialData: ResultsInformation) {
availabilities: optimisticAvailabilities,
filteredAvailabilities,
gridNumParticipants,
+ validParticipantsForList,
// User Info
currentUser,
@@ -181,6 +206,7 @@ export function useEventResults(initialData: ResultsInformation) {
selectedParticipants,
showOnlyBestTimes,
timezone,
+ minAvailability,
// Actions
clearSelectedParticipants: () => setSelectedParticipants([]),
@@ -190,5 +216,6 @@ export function useEventResults(initialData: ResultsInformation) {
handleRemoveParticipant,
setShowOnlyBestTimes,
setTimezone,
+ setMinAvailability,
};
}
diff --git a/frontend/src/features/event/results/lib/utils.ts b/frontend/src/features/event/results/lib/utils.ts
index 1b915e1c1..502392a85 100644
--- a/frontend/src/features/event/results/lib/utils.ts
+++ b/frontend/src/features/event/results/lib/utils.ts
@@ -9,10 +9,12 @@ import { ResultsAvailabilityMap } from "@/core/availability/types";
*/
export function hasMutualAvailability(
availabilities: ResultsAvailabilityMap,
- participants: string[],
+ participantCount: number,
): boolean {
+ if (participantCount === 0) return false;
+
for (const slot in availabilities) {
- if (availabilities[slot].length === participants.length) {
+ if (availabilities[slot].length === participantCount) {
return true;
}
}
@@ -29,7 +31,7 @@ export function hasMutualAvailability(
*/
export function findConsensusAndConflicts(
availabilities: ResultsAvailabilityMap,
- participants: string[],
+ participantCount: number,
): {
allAvailableSlots: string[];
noOneAvailableSlots: string[];
@@ -37,14 +39,17 @@ export function findConsensusAndConflicts(
const allAvailableSlots: string[] = [];
const noOneAvailableSlots: string[] = [];
- for (const slot in availabilities) {
- const availableParticipants = availabilities[slot];
+ // early return for no participants
+ if (participantCount === 0) {
+ return { allAvailableSlots, noOneAvailableSlots };
+ }
- if (availableParticipants.length === participants.length) {
- allAvailableSlots.push(slot);
- }
+ for (const [slot, availableParticipants] of Object.entries(availabilities)) {
+ const count = availableParticipants.length;
- if (availableParticipants.length === 0) {
+ if (count === participantCount) {
+ allAvailableSlots.push(slot);
+ } else if (count === 0) {
noOneAvailableSlots.push(slot);
}
}
diff --git a/frontend/src/lib/messages.ts b/frontend/src/lib/messages.ts
index e298bfb1d..0ceebb2d2 100644
--- a/frontend/src/lib/messages.ts
+++ b/frontend/src/lib/messages.ts
@@ -69,9 +69,8 @@ export const MESSAGES = {
"Select dates and times to see a preview of the event grid.",
INFO_UNSELECTED_WEEK_RANGE:
"Select days and times to see a preview of the event grid.",
- 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.",
+ INFO_NO_OVERLAP:
+ "Literally no one is free at the same time. This is NOT making it out of the group chat...",
+ INFO_NO_IDEAL_TIMES:
+ "There are no times that work for everyone. Times with an indicator are the best options.",
};