diff --git a/components/autocomplete/language-autocomplete.tsx b/components/autocomplete/language-autocomplete.tsx index 303d9ad..2b6f664 100644 --- a/components/autocomplete/language-autocomplete.tsx +++ b/components/autocomplete/language-autocomplete.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { Check, ChevronsUpDown, X } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" import { Command, CommandEmpty, @@ -35,14 +36,16 @@ interface LanguageAutocompleteProps { mobilePlaceholder?: string } +const MAX_VISIBLE_BADGES = 6 + export const LanguageAutocomplete = React.forwardRef< { reset: () => void }, LanguageAutocompleteProps ->(function LanguageAutocomplete({ - languages, - onSelect, +>(function LanguageAutocomplete({ + languages, + onSelect, selectedLanguages, - maxSelections = 8, + maxSelections = 6, 'aria-label': ariaLabel = "Select languages", className, placeholder = "Filter by languages", @@ -60,18 +63,14 @@ export const LanguageAutocomplete = React.forwardRef< })) const formattedLanguages = useMemo(() => { - // Create a Map to ensure unique language entries by code const languageMap = new Map() - - // Filter out any 'All' entries from the input languages - const filteredLanguages = languages.filter(lang => + + const filteredLanguages = languages.filter(lang => typeof lang === 'string' ? lang !== 'All' : lang.code !== 'All' ) for (const lang of filteredLanguages) { - // If it's a string, assume it's a language name (not code) if (typeof lang === 'string') { - // Find the language code by name const entry = Object.entries(countryLanguages).find(([_, data]) => data.name === lang) if (entry) { const [code, data] = entry @@ -81,7 +80,6 @@ export const LanguageAutocomplete = React.forwardRef< display: `${data.name} (${code})` }) } else { - // If not found in countryLanguages, use the string as is languageMap.set(lang, { code: lang, name: lang, @@ -89,7 +87,6 @@ export const LanguageAutocomplete = React.forwardRef< }) } } else if (typeof lang === 'object' && 'code' in lang) { - // If it's already an object with a code, format it properly const code = lang.code if (!languageMap.has(code)) { languageMap.set(code, { @@ -104,8 +101,8 @@ export const LanguageAutocomplete = React.forwardRef< return Array.from(languageMap.values()) }, [languages]) - const filteredLanguages = React.useMemo(() => - formattedLanguages.filter(language => + const filteredLanguages = React.useMemo(() => + formattedLanguages.filter(language => language.display.toLowerCase().includes(value.toLowerCase()) || language.code.toLowerCase().includes(value.toLowerCase()) ), @@ -114,22 +111,25 @@ export const LanguageAutocomplete = React.forwardRef< const handleToggleLanguage = (languageCode: string) => { const isSelected = selectedLanguages.includes(languageCode) - + if (isSelected) { - // Remove language onSelect(selectedLanguages.filter(l => l !== languageCode)) } else if (selectedLanguages.length < maxSelections) { - // Add language (max reached check) onSelect([...selectedLanguages, languageCode]) } - // If max reached and not selected, do nothing } + // Get language name for badge display + const getLanguageName = (code: string) => { + const lang = formattedLanguages.find(l => l.code === code) + return lang?.name ?? code + } + + const visibleBadges = selectedLanguages.slice(0, MAX_VISIBLE_BADGES) + const extraCount = selectedLanguages.length - MAX_VISIBLE_BADGES + return ( - + + + ))} + {extraCount > 0 && ( + +{extraCount} more + )} + )} - + - - + {filteredLanguages.length === 0 ? ( No languages found. ) : ( @@ -177,7 +199,7 @@ export const LanguageAutocomplete = React.forwardRef< {filteredLanguages.map((language) => { const isSelected = selectedLanguages.includes(language.code) const canSelect = selectedLanguages.length < maxSelections || isSelected - + return ( - + {filteredLocations.length === 0 ? ( No locations found. ) : ( diff --git a/components/autocomplete/proximity-autocomplete.tsx b/components/autocomplete/proximity-autocomplete.tsx index e0110ea..b0006d1 100644 --- a/components/autocomplete/proximity-autocomplete.tsx +++ b/components/autocomplete/proximity-autocomplete.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { Check, ChevronsUpDown } from "lucide-react" +import { Check, ChevronsUpDown, X } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { @@ -15,12 +15,12 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover" -import { forwardRef, useImperativeHandle, useState, useMemo } from 'react' +import { useState, useMemo } from 'react' import type { TimeType } from '@/components/same-time/types' interface ProximityAutocompleteProps { - value: TimeType - onSelect: (value: TimeType) => void + values: TimeType[] + onSelect: (values: TimeType[]) => void 'aria-label'?: string className?: string placeholder?: string @@ -33,7 +33,6 @@ interface ProximityOption { } const PROXIMITY_OPTIONS: ProximityOption[] = [ - { value: 'All', label: 'All Proximities', display: 'All Proximities' }, { value: 'Same Time', label: 'Same Time', display: '✅ Same Time' }, { value: 'Close Time', label: 'Close Time', display: '☑️ Close Time' }, { value: 'Reverse Time', label: 'Reverse Time', display: '😵‍💫 Reverse Time' }, @@ -42,48 +41,49 @@ const PROXIMITY_OPTIONS: ProximityOption[] = [ export const ProximityAutocomplete = React.forwardRef< { reset: () => void }, ProximityAutocompleteProps ->(function ProximityAutocomplete({ - value, +>(function ProximityAutocomplete({ + values, onSelect, 'aria-label': ariaLabel = "Filter by proximity", className, - placeholder = "Filter by Proximity" + placeholder = "All Proximities", }, ref) { const [open, setOpen] = useState(false) const [searchValue, setSearchValue] = useState('') - useImperativeHandle(ref, () => ({ + React.useImperativeHandle(ref, () => ({ reset: () => { setSearchValue('') - onSelect('All') + onSelect([]) setOpen(false) } })) const filteredOptions = useMemo(() => { - if (!searchValue) { - return PROXIMITY_OPTIONS - } - return PROXIMITY_OPTIONS.filter(option => + if (!searchValue) return PROXIMITY_OPTIONS + return PROXIMITY_OPTIONS.filter(option => option.label.toLowerCase().includes(searchValue.toLowerCase()) || option.display.toLowerCase().includes(searchValue.toLowerCase()) ) }, [searchValue]) - const selectedOption = PROXIMITY_OPTIONS.find(opt => opt.value === value) - const displayText = selectedOption?.display || placeholder - - const handleSelect = (selectedValue: TimeType) => { - onSelect(selectedValue) - setOpen(false) - setSearchValue('') + const handleToggle = (value: TimeType) => { + const isSelected = values.includes(value) + if (isSelected) { + onSelect(values.filter(v => v !== value)) + } else { + onSelect([...values, value]) + } } + const triggerLabel = values.length === 0 + ? placeholder + : values.length === 1 + ? PROXIMITY_OPTIONS.find(o => o.value === values[0])?.display ?? values[0] + : `${values.length} proximities` + return ( - + - + - @@ -109,27 +109,39 @@ export const ProximityAutocomplete = React.forwardRef< {filteredOptions.length === 0 ? ( No proximity types found. ) : ( - - {filteredOptions.map((option) => { - const isSelected = value === option.value - - return ( + <> + {values.length > 0 && ( + handleSelect(option.value)} + onSelect={() => onSelect([])} + className="text-destructive" > - - {option.display} + + Clear all proximities - ) - })} - + + )} + + {filteredOptions.map((option) => { + const isSelected = values.includes(option.value) + return ( + handleToggle(option.value)} + > + + {option.display} + + ) + })} + + )} diff --git a/components/autocomplete/time-of-day-autocomplete.tsx b/components/autocomplete/time-of-day-autocomplete.tsx new file mode 100644 index 0000000..7539534 --- /dev/null +++ b/components/autocomplete/time-of-day-autocomplete.tsx @@ -0,0 +1,141 @@ +import * as React from 'react' +import { Check, ChevronsUpDown, X } from "lucide-react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { useState, useMemo } from 'react' +import type { TimeOfDay } from '@/components/same-time/types' + +interface TimeOfDayAutocompleteProps { + availableTimesOfDay: TimeOfDay[] + values: TimeOfDay[] + onSelect: (values: TimeOfDay[]) => void + 'aria-label'?: string + className?: string + placeholder?: string +} + +const TIME_OF_DAY_EMOJIS: Record = { + 'Early Morning': '🌅', + 'Morning': '☀️', + 'Afternoon': '🌤️', + 'Evening': '🌆', + 'Night': '🌙', + 'Late Night': '🌃', +} + +export function TimeOfDayAutocomplete({ + availableTimesOfDay, + values, + onSelect, + 'aria-label': ariaLabel = "Filter by time of day", + className, + placeholder = "All Times of Day", +}: TimeOfDayAutocompleteProps) { + const [open, setOpen] = useState(false) + const [searchValue, setSearchValue] = useState('') + + const filteredOptions = useMemo(() => { + if (!searchValue) return availableTimesOfDay + return availableTimesOfDay.filter(tod => + tod.toLowerCase().includes(searchValue.toLowerCase()) + ) + }, [availableTimesOfDay, searchValue]) + + const handleToggle = (tod: TimeOfDay) => { + const isSelected = values.includes(tod) + if (isSelected) { + onSelect(values.filter(v => v !== tod)) + } else { + onSelect([...values, tod]) + } + } + + const triggerLabel = values.length === 0 + ? placeholder + : values.length === 1 + ? `${TIME_OF_DAY_EMOJIS[values[0]]} ${values[0]}` + : `${values.length} times of day` + + return ( + + + + + + + + + {filteredOptions.length === 0 ? ( + No times found. + ) : ( + <> + {values.length > 0 && ( + + onSelect([])} + className="text-destructive" + > + + Clear all times + + + )} + + {filteredOptions.map((tod) => { + const isSelected = values.includes(tod) + return ( + handleToggle(tod)} + > + + + {TIME_OF_DAY_EMOJIS[tod]} {tod} + + + ) + })} + + + )} + + + + + ) +} + +TimeOfDayAutocomplete.displayName = 'TimeOfDayAutocomplete' diff --git a/components/same-time/client.tsx b/components/same-time/client.tsx index 3020636..40cffa4 100644 --- a/components/same-time/client.tsx +++ b/components/same-time/client.tsx @@ -37,11 +37,11 @@ export function SameTimeClient({ initialData }: SameTimeClientProps) { const languageAutocompleteRef = useRef<{ reset: () => void; }>({ reset: () => {} }) // Get location state - const { - selectedTimeType: urlTimeType, - setTimeType: setUrlTimeType, - selectedTimeOfDay: urlTimeOfDay, - setTimeOfDay: setUrlTimeOfDay, + const { + selectedTimeTypes: urlTimeTypes, + setTimeTypes: setUrlTimeTypes, + selectedTimesOfDay: urlTimesOfDay, + setTimesOfDay: setUrlTimesOfDay, selectedLanguages, toggleLanguage, batchUpdate, @@ -90,17 +90,17 @@ export function SameTimeClient({ initialData }: SameTimeClientProps) { const fetchAndUpdateLocationsRef = useRef<((timezone: string, showToast?: boolean) => Promise) | null>(null) // Use URL state directly - no need for local state duplication - const selectedTimeType = urlTimeType - const selectedTimeOfDay = urlTimeOfDay + const selectedTimeTypes = urlTimeTypes + const selectedTimesOfDay = urlTimesOfDay // Update handlers to only set URL state - const handleTimeTypeChange = useCallback((type: TimeType) => { - setUrlTimeType(type) - }, [setUrlTimeType]) + const handleTimeTypesChange = useCallback((types: TimeType[]) => { + setUrlTimeTypes(types) + }, [setUrlTimeTypes]) - const handleTimeOfDayChange = useCallback((timeOfDay: TimeOfDay) => { - setUrlTimeOfDay(timeOfDay) - }, [setUrlTimeOfDay]) + const handleTimesOfDayChange = useCallback((timesOfDay: TimeOfDay[]) => { + setUrlTimesOfDay(timesOfDay) + }, [setUrlTimesOfDay]) // Fetch and update locations using Server Action const fetchAndUpdateLocations = useCallback(async (timezone: string, showToast?: boolean) => { @@ -212,7 +212,7 @@ export function SameTimeClient({ initialData }: SameTimeClientProps) { addSelectedTimezone(encoded) // Batch URL updates to avoid History API throttling - batchUpdate({ timeType: 'All', timeOfDay: 'All', language: [], page: 1 }) + batchUpdate({ timeType: [], timeOfDay: [], language: [], page: 1 }) // Update the searched city state setSearchedCity(searchedCity || null) @@ -237,8 +237,8 @@ export function SameTimeClient({ initialData }: SameTimeClientProps) { const handleReset = useCallback(() => { // Batch all URL state updates into a single call to avoid History API throttling batchUpdate({ - timeType: 'All', - timeOfDay: 'All', + timeType: [], + timeOfDay: [], language: [], page: 1, sortField: 'type', @@ -279,14 +279,13 @@ export function SameTimeClient({ initialData }: SameTimeClientProps) { ) const timeOfDay = getTimeOfDay(location.localHour) - const matchesTimeType = selectedTimeType === 'All' || timeType === selectedTimeType - const matchesTimeOfDay = selectedTimeOfDay === 'All' || timeOfDay === selectedTimeOfDay + const matchesTimeType = selectedTimeTypes.length === 0 || selectedTimeTypes.includes(timeType as TimeType) + const matchesTimeOfDay = selectedTimesOfDay.length === 0 || selectedTimesOfDay.includes(timeOfDay as TimeOfDay) const matchesPriority = showAllCountries || PRIORITY_COUNTRIES.includes(location.countryName) return matchesTimeType && matchesTimeOfDay && matchesPriority }) - // Then get languages only from filtered locations const langMap = new Map() for (const location of filteredLocations) { for (const lang of location.languages) { @@ -308,7 +307,7 @@ export function SameTimeClient({ initialData }: SameTimeClientProps) { } } return Array.from(langMap.values()).sort((a, b) => a.name.localeCompare(b.name)) - }, [data.locations, data.userTimezone?.currentTimeOffsetInMinutes, selectedTimeType, selectedTimeOfDay, showAllCountries]) + }, [data.locations, data.userTimezone?.currentTimeOffsetInMinutes, selectedTimeTypes, selectedTimesOfDay, showAllCountries]) // Calculate available times of day const availableTimesOfDay = useMemo(() => { @@ -354,7 +353,7 @@ export function SameTimeClient({ initialData }: SameTimeClientProps) { {(selectedLocations || []).length > 0 && ( -
+
{(selectedLocations || []).map((location, index) => ( -
- & { +}: Omit & { animationConfig?: AnimationConfig selectedLocations?: Location[] }) { @@ -67,14 +67,14 @@ export function FilterControls({ // Check if any filters are active useEffect(() => { - const hasActiveFilters = - selectedTimeType !== 'All' || - selectedTimeOfDay !== 'All' || + const hasActiveFilters = + selectedTimeTypes.length > 0 || + selectedTimesOfDay.length > 0 || selectedLanguages.length > 0 || selectedLocations.length > 0 - + setShowReset(hasActiveFilters) - }, [selectedTimeType, selectedTimeOfDay, selectedLanguages, selectedLocations]) + }, [selectedTimeTypes, selectedTimesOfDay, selectedLanguages, selectedLocations]) const handleReset = (e: React.MouseEvent) => { e.preventDefault() @@ -100,55 +100,58 @@ export function FilterControls({ const validTimeTypes: TimeType[] = ['All', 'Same Time', 'Close Time', 'Reverse Time'] const validTimeOfDay: TimeOfDay[] = ['All', 'Early Morning', 'Morning', 'Afternoon', 'Evening', 'Night', 'Late Night'] - if (!validTimeTypes.includes(selectedTimeType) || !validTimeOfDay.includes(selectedTimeOfDay)) { + if ( + !selectedTimeTypes.every(t => validTimeTypes.includes(t)) || + !selectedTimesOfDay.every(t => validTimeOfDay.includes(t)) + ) { return null } // 4. render return ( - {/* Top row - Location selector with toggle on larger screens */} - - - loc.timezone === userTimezone?.name)} - aria-label="Select up to 3 timezones" + aria-label="Select up to 4 timezones" showAllCountries={scrollMode === 'infinite'} priorityCountries={PRIORITY_COUNTRIES} className="w-full" /> - - {/* Bottom row - Other filters */} - - - {/* Show toggle only on mobile */} - - - - - - + className="w-full" + /> {showReset && ( @@ -278,4 +271,4 @@ export function FilterControls({ ) -} \ No newline at end of file +} diff --git a/components/same-time/locations-table.tsx b/components/same-time/locations-table.tsx index 5708db2..58f0517 100644 --- a/components/same-time/locations-table.tsx +++ b/components/same-time/locations-table.tsx @@ -40,8 +40,8 @@ interface LocationsTableProps { userTimezone: UserTimezone | null selectedLocations?: Location[] onLocationChangeAction: (location: Location) => void - selectedTimeType: TimeType - selectedTimeOfDay: TimeOfDay + selectedTimeTypes: TimeType[] + selectedTimesOfDay: TimeOfDay[] showAllCountries: boolean priorityCountries: string[] scrollMode: 'pagination' | 'infinite' @@ -209,13 +209,13 @@ const calculateSmartPagination = (totalItems: number) => { } } -export default function LocationsTable({ - locations, +export default function LocationsTable({ + locations, userTimezone, selectedLocations = [], onLocationChangeAction, - selectedTimeType: _selectedTimeType, - selectedTimeOfDay: _selectedTimeOfDay, + selectedTimeTypes: _selectedTimeTypes, + selectedTimesOfDay: _selectedTimesOfDay, showAllCountries, priorityCountries, scrollMode = 'pagination', @@ -295,7 +295,8 @@ export default function LocationsTable({ sortDirection, setSortDirection, toggleLanguage, - setTimeType + toggleTimeType, + selectedTimeTypes: activeTimeTypes, } = useLocationState() const handleLocationSelect = useCallback((location: Location) => { @@ -309,8 +310,8 @@ export default function LocationsTable({ const handleProximityClick = useCallback((e: React.MouseEvent, type: TimeType) => { e.stopPropagation() - setTimeType(type) - }, [setTimeType]) + toggleTimeType(type) + }, [toggleTimeType]) const filteredAndSortedLocations = useLocationFilters(locations, userTimezone, selectedLocations) @@ -629,7 +630,7 @@ export default function LocationsTable({ ) as TimeType | 'Different Time' const firstWord = type.split(' ')[0] const isFilterableType = type === 'Same Time' || type === 'Close Time' || type === 'Reverse Time' - const isActiveFilter = _selectedTimeType === type + const isActiveFilter = activeTimeTypes.includes(type as TimeType) const badgeInfo = getProximityBadgeColor( location.currentTimeOffsetInMinutes, @@ -637,7 +638,7 @@ export default function LocationsTable({ userTimezone?.currentTimeOffsetInMinutes || 0, userTimezone?.countryName || null, selectedLocations || [], - _selectedTimeType + activeTimeTypes ) const BadgeContent = ( @@ -891,7 +892,7 @@ export default function LocationsTable({ searchedCity, openStates, userTimezone, - _selectedTimeType, + activeTimeTypes, selectedLanguages, getLanguageBadgeColor, handleLanguageClick, diff --git a/components/same-time/types.ts b/components/same-time/types.ts index 465148e..f3cd6b1 100644 --- a/components/same-time/types.ts +++ b/components/same-time/types.ts @@ -63,12 +63,12 @@ export interface FilterControlsProps { locations: Location[] userTimezone: UserTimezone | null availableLanguages: (string | LanguageInfo)[] - selectedTimeType: TimeType - selectedTimeOfDay: TimeOfDay + selectedTimeTypes: TimeType[] + selectedTimesOfDay: TimeOfDay[] selectedLanguage: string onLocationChange: (location: Location) => void - onTimeTypeChange: (type: TimeType) => void - onTimeOfDayChange: (timeOfDay: TimeOfDay) => void + onTimeTypesChange: (types: TimeType[]) => void + onTimesOfDayChange: (timesOfDay: TimeOfDay[]) => void onLanguageChange: (language: string) => void availableTimesOfDay: TimeOfDay[] onReset: () => void @@ -84,8 +84,8 @@ export interface LocationsTableProps { locations: Location[] userTimezone: UserTimezone | null onLocationChangeAction: (location: Location, searchedCity?: string) => void - selectedTimeType: TimeType - selectedTimeOfDay: TimeOfDay + selectedTimeTypes: TimeType[] + selectedTimesOfDay: TimeOfDay[] showAllCountries: boolean priorityCountries: string[] scrollMode: 'pagination' | 'infinite' diff --git a/components/same-time/utils.ts b/components/same-time/utils.ts index 1518bb3..ea74ae4 100644 --- a/components/same-time/utils.ts +++ b/components/same-time/utils.ts @@ -98,21 +98,21 @@ export function formatTimezoneName(timezone: string): string { } /** - * Helper function to check if two locations match the selected proximity type + * Helper function to check if two locations match any of the selected proximity types */ function matchesProximity( locationOffset: number, referenceOffset: number, isSimilarTime: boolean, - proximityFilter: TimeType + proximityFilters: TimeType[] ): boolean { const timeType = getTimeType(locationOffset, referenceOffset, isSimilarTime) - return proximityFilter === 'All' || timeType === proximityFilter + return proximityFilters.length === 0 || proximityFilters.includes(timeType as TimeType) } /** * Get the badge info (color and label) for a location based on proximity filter with priority cascade - * Priority: User timezone (green) > 1st selected (blue) > 2nd selected (pink) > 3rd selected (purple) + * Priority: User timezone (green) > 1st selected (blue) > 2nd selected (pink) > 3rd selected (purple) > 4th selected (emerald) */ export function getProximityBadgeColor( locationOffset: number, @@ -120,15 +120,15 @@ export function getProximityBadgeColor( userOffset: number, userCountryName: string | null, selectedLocations: Location[], - proximityFilter: TimeType + proximityFilters: TimeType[] ): { color: string; label: string } | null { // If no proximity filter is active, return null - if (proximityFilter === 'All') { + if (proximityFilters.length === 0) { return null } // Priority 1: Check if matches user timezone - if (userCountryName && matchesProximity(locationOffset, userOffset, locationIsSimilarTime, proximityFilter)) { + if (userCountryName && matchesProximity(locationOffset, userOffset, locationIsSimilarTime, proximityFilters)) { return { color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', label: userCountryName @@ -140,7 +140,7 @@ export function getProximityBadgeColor( locationOffset, selectedLocations[0].currentTimeOffsetInMinutes, locationIsSimilarTime, - proximityFilter + proximityFilters )) { return { color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', @@ -153,7 +153,7 @@ export function getProximityBadgeColor( locationOffset, selectedLocations[1].currentTimeOffsetInMinutes, locationIsSimilarTime, - proximityFilter + proximityFilters )) { return { color: 'bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-300', @@ -166,7 +166,7 @@ export function getProximityBadgeColor( locationOffset, selectedLocations[2].currentTimeOffsetInMinutes, locationIsSimilarTime, - proximityFilter + proximityFilters )) { return { color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', @@ -174,6 +174,19 @@ export function getProximityBadgeColor( } } + // Priority 5: Check if matches 4th selected timezone + if (selectedLocations[3] && matchesProximity( + locationOffset, + selectedLocations[3].currentTimeOffsetInMinutes, + locationIsSimilarTime, + proximityFilters + )) { + return { + color: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300', + label: selectedLocations[3].countryName + } + } + // No match - don't show badge return null } \ No newline at end of file diff --git a/hooks/use-location-filters.ts b/hooks/use-location-filters.ts index 1a5b50f..96ac7ab 100644 --- a/hooks/use-location-filters.ts +++ b/hooks/use-location-filters.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import type { Location, UserTimezone } from '@/components/same-time/types' +import type { Location, UserTimezone, TimeType, TimeOfDay } from '@/components/same-time/types' import { getTimeType, getTimeOfDay } from '@/components/same-time/utils' import { useLocationState } from './use-location-state' @@ -8,10 +8,10 @@ export function useLocationFilters( userTimezone: UserTimezone | null, selectedLocations: Location[] = [] ) { - const { - selectedLanguages, - selectedTimeType, - selectedTimeOfDay, + const { + selectedLanguages, + selectedTimeTypes, + selectedTimesOfDay, sortField, sortDirection } = useLocationState() @@ -31,9 +31,9 @@ export function useLocationFilters( ) } - // Filter by time type (proximity) if selected - // Check if location matches proximity to ANY of the selected timezones (user + 1-3 selections) - if (selectedTimeType !== 'All') { + // Filter by time type (proximity) if any selected (OR logic) + // Check if location matches proximity to ANY of the selected timezones (user + selections) + if (selectedTimeTypes.length > 0) { filtered = filtered.filter(location => { // Check against user timezone const typeVsUser = getTimeType( @@ -41,7 +41,7 @@ export function useLocationFilters( userTimezone?.currentTimeOffsetInMinutes || 0, location.isSimilarTime ) - if (typeVsUser === selectedTimeType) { + if (selectedTimeTypes.includes(typeVsUser as TimeType)) { return true } @@ -52,7 +52,7 @@ export function useLocationFilters( selected.currentTimeOffsetInMinutes, location.isSimilarTime ) - if (typeVsSelected === selectedTimeType) { + if (selectedTimeTypes.includes(typeVsSelected as TimeType)) { return true } } @@ -61,11 +61,11 @@ export function useLocationFilters( }) } - // Filter by time of day if selected - if (selectedTimeOfDay !== 'All') { + // Filter by time of day if any selected (OR logic) + if (selectedTimesOfDay.length > 0) { filtered = filtered.filter(location => { const timeOfDay = getTimeOfDay(location.localHour) - return timeOfDay === selectedTimeOfDay + return selectedTimesOfDay.includes(timeOfDay as TimeOfDay) }) } @@ -127,5 +127,5 @@ export function useLocationFilters( } return filtered - }, [locations, selectedLanguages, selectedTimeType, selectedTimeOfDay, userTimezone, sortField, sortDirection, selectedLocations]) + }, [locations, selectedLanguages, selectedTimeTypes, selectedTimesOfDay, userTimezone, sortField, sortDirection, selectedLocations]) } \ No newline at end of file diff --git a/hooks/use-location-state.ts b/hooks/use-location-state.ts index 5bae842..8dce521 100644 --- a/hooks/use-location-state.ts +++ b/hooks/use-location-state.ts @@ -25,11 +25,11 @@ export function useLocationState() { if (isSelected) { // Remove language setState({ language: current.filter(l => l !== lang), page: 1 }) - } else if (current.length < 8) { - // Add language (max 8) + } else if (current.length < 6) { + // Add language (max 6) setState({ language: [...current, lang], page: 1 }) } - // If already at 8, do nothing (could show toast in calling component) + // If already at 6, do nothing (could show toast in calling component) }, [state.language, setState]) // Selected timezone management (max 3) @@ -45,8 +45,8 @@ export function useLocationState() { return } - // Add to front, keep max 3 - const updated = [encoded, ...current].slice(0, 3) + // Add to front, keep max 4 + const updated = [encoded, ...current].slice(0, 4) setState({ selectedTzs: updated, page: 1 }) }, [state.selectedTzs, setState]) @@ -105,20 +105,42 @@ export function useLocationState() { // biome-ignore lint/correctness/useExhaustiveDependencies: Only restore once on mount }, []) // Only run once on mount + const toggleTimeType = useCallback((type: TimeType) => { + const current = state.timeType as TimeType[] + const isSelected = current.includes(type) + if (isSelected) { + setState({ timeType: current.filter(t => t !== type), page: 1 }) + } else { + setState({ timeType: [...current, type], page: 1 }) + } + }, [state.timeType, setState]) + + const toggleTimeOfDay = useCallback((tod: TimeOfDay) => { + const current = state.timeOfDay as TimeOfDay[] + const isSelected = current.includes(tod) + if (isSelected) { + setState({ timeOfDay: current.filter(t => t !== tod), page: 1 }) + } else { + setState({ timeOfDay: [...current, tod], page: 1 }) + } + }, [state.timeOfDay, setState]) + return { selectedLanguages: state.language, setLanguages: (langs: string[]) => { setState({ language: langs, page: 1 }) }, toggleLanguage, - selectedTimeType: state.timeType as TimeType, - setTimeType: (type: TimeType) => { - setState({ timeType: type, page: 1 }) + selectedTimeTypes: state.timeType as TimeType[], + setTimeTypes: (types: TimeType[]) => { + setState({ timeType: types, page: 1 }) }, - selectedTimeOfDay: state.timeOfDay as TimeOfDay, - setTimeOfDay: (timeOfDay: TimeOfDay) => { - setState({ timeOfDay, page: 1 }) + toggleTimeType, + selectedTimesOfDay: state.timeOfDay as TimeOfDay[], + setTimesOfDay: (timesOfDay: TimeOfDay[]) => { + setState({ timeOfDay: timesOfDay, page: 1 }) }, + toggleTimeOfDay, page: state.page, setPage: (page: number) => { setState({ page }) @@ -134,7 +156,7 @@ export function useLocationState() { // Selected timezones selectedTimezones: state.selectedTzs, setSelectedTimezones: (tzs: string[]) => { - setState({ selectedTzs: tzs.slice(0, 3) }) // Max 3 + setState({ selectedTzs: tzs.slice(0, 4) }) // Max 4 }, addSelectedTimezone, removeSelectedTimezone, diff --git a/lib/search-params.ts b/lib/search-params.ts index a82aa8f..62d9eaf 100644 --- a/lib/search-params.ts +++ b/lib/search-params.ts @@ -3,8 +3,8 @@ import { parseAsString, parseAsInteger, parseAsArrayOf, parseAsBoolean, createSe // Shared parsers for both server and client export const locationParsers = { language: parseAsArrayOf(parseAsString).withDefault([]), - timeType: parseAsString.withDefault('All'), - timeOfDay: parseAsString.withDefault('All'), + timeType: parseAsArrayOf(parseAsString).withDefault([]), + timeOfDay: parseAsArrayOf(parseAsString).withDefault([]), page: parseAsInteger.withDefault(1), sortField: parseAsString.withDefault('type'), sortDirection: parseAsString.withDefault('asc'),