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