From 192b8851494e952a35d5f2464b2a47a7d3a6a58c Mon Sep 17 00:00:00 2001 From: Joyce <26967919+Joyce-O@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:49:11 -0500 Subject: [PATCH] imporvements --- backend/src/app/main.py | 3 +- backend/src/app/schemas.py | 1 + frontend/src/api/client.ts | 4 +- .../src/components/AvailabilityHeatmap.tsx | 81 +++++++++++++++++-- .../src/components/ParticipantSelector.tsx | 56 +++++++++++-- frontend/src/pages/Index.tsx | 7 +- 6 files changed, 134 insertions(+), 18 deletions(-) diff --git a/backend/src/app/main.py b/backend/src/app/main.py index 9868424..bb8dc79 100644 --- a/backend/src/app/main.py +++ b/backend/src/app/main.py @@ -144,7 +144,8 @@ async def delete_participant(participant_id: UUID, db: AsyncSession = Depends(ge async def get_availability( request: AvailabilityRequest, db: AsyncSession = Depends(get_db) ): - slots = await calculate_availability(db, request.participant_ids) + reference_date = datetime.now(timezone.utc) + timedelta(weeks=request.week_offset) + slots = await calculate_availability(db, request.participant_ids, reference_date) return {"slots": slots} diff --git a/backend/src/app/schemas.py b/backend/src/app/schemas.py index 41c15df..f233f15 100644 --- a/backend/src/app/schemas.py +++ b/backend/src/app/schemas.py @@ -39,6 +39,7 @@ class TimeSlot(BaseModel): class AvailabilityRequest(BaseModel): participant_ids: list[UUID] + week_offset: int = 0 class AvailabilityResponse(BaseModel): diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1e1c9ab..2ffdd40 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -71,11 +71,11 @@ export async function deleteParticipant(id: string): Promise { } } -export async function fetchAvailability(participantIds: string[]): Promise { +export async function fetchAvailability(participantIds: string[], weekOffset: number = 0): Promise { const response = await fetch(`${API_URL}/api/availability`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ participant_ids: participantIds }), + body: JSON.stringify({ participant_ids: participantIds, week_offset: weekOffset }), }); const data = await handleResponse<{ slots: TimeSlotAPI[] }>(response); return data.slots; diff --git a/frontend/src/components/AvailabilityHeatmap.tsx b/frontend/src/components/AvailabilityHeatmap.tsx index 399ff88..e6b8cd0 100644 --- a/frontend/src/components/AvailabilityHeatmap.tsx +++ b/frontend/src/components/AvailabilityHeatmap.tsx @@ -6,15 +6,16 @@ import { PopoverTrigger, } from '@/components/ui/popover'; import { Button } from '@/components/ui/button'; -import { Check, X, Loader2 } from 'lucide-react'; +import { useState } from 'react'; +import { Check, X, Loader2, ChevronLeft, ChevronRight, ChevronsRight } from 'lucide-react'; const TIMEZONE = 'America/Toronto'; const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']; const hours = [9, 10, 11, 12, 13, 14, 15, 16, 17]; -// Get the dates for Mon-Fri of the current week in a specific timezone -const getWeekDates = (timezone: string): string[] => { +// Get the dates for Mon-Fri of a week in a specific timezone, offset by N weeks +const getWeekDates = (timezone: string, weekOffset: number = 0): string[] => { // Get "now" in the target timezone const now = new Date(); const formatter = new Intl.DateTimeFormat('en-CA', { @@ -33,7 +34,7 @@ const getWeekDates = (timezone: string): string[] => { const dayOfWeek = todayDate.getDay(); const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; - const mondayDate = new Date(year, month - 1, day + daysToMonday); + const mondayDate = new Date(year, month - 1, day + daysToMonday + weekOffset * 7); return dayNames.map((_, i) => { const d = new Date(mondayDate); @@ -73,12 +74,18 @@ const toUTCDate = (dateStr: string, hour: number, timezone: string): Date => { return utcDate; }; +const MIN_WEEK_OFFSET = 0; +const DEFAULT_MAX_WEEK_OFFSET = 1; +const EXPANDED_MAX_WEEK_OFFSET = 4; + interface AvailabilityHeatmapProps { slots: TimeSlot[]; selectedParticipants: Participant[]; onSlotSelect: (slot: TimeSlot) => void; showPartialAvailability?: boolean; isLoading?: boolean; + weekOffset?: number; + onWeekOffsetChange?: (offset: number) => void; } export const AvailabilityHeatmap = ({ @@ -87,8 +94,12 @@ export const AvailabilityHeatmap = ({ onSlotSelect, showPartialAvailability = false, isLoading = false, + weekOffset = 0, + onWeekOffsetChange, }: AvailabilityHeatmapProps) => { - const weekDates = getWeekDates(TIMEZONE); + const [expanded, setExpanded] = useState(false); + const maxWeekOffset = expanded ? EXPANDED_MAX_WEEK_OFFSET : DEFAULT_MAX_WEEK_OFFSET; + const weekDates = getWeekDates(TIMEZONE, weekOffset); // Find a slot that matches the given display timezone date/hour const getSlot = (dateStr: string, hour: number): TimeSlot | undefined => { @@ -167,13 +178,67 @@ export const AvailabilityHeatmap = ({
-

- Common Availability — Week of {getWeekDateRange()} -

+
+

+ Common Availability — Week of {getWeekDateRange()} +

+

{selectedParticipants.length} participant{selectedParticipants.length > 1 ? 's' : ''}: {selectedParticipants.map(p => p.name.split(' ')[0]).join(', ')}

+ {onWeekOffsetChange && ( +
+ + {weekOffset !== 0 && ( + + )} + {weekOffset < maxWeekOffset ? ( + + ) : !expanded ? ( + + ) : ( + + )} +
+ )}
diff --git a/frontend/src/components/ParticipantSelector.tsx b/frontend/src/components/ParticipantSelector.tsx index b47e42c..11e0b7a 100644 --- a/frontend/src/components/ParticipantSelector.tsx +++ b/frontend/src/components/ParticipantSelector.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useRef, useCallback, useEffect } from 'react'; import { Participant } from '@/types/calendar'; import { Input } from '@/components/ui/input'; import { X, Plus, Search, AlertCircle } from 'lucide-react'; @@ -17,6 +17,19 @@ export const ParticipantSelector = ({ }: ParticipantSelectorProps) => { const [searchQuery, setSearchQuery] = useState(''); const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const inputRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); const filteredParticipants = participants.filter( (p) => @@ -25,14 +38,40 @@ export const ParticipantSelector = ({ p.email.toLowerCase().includes(searchQuery.toLowerCase())) ); - const addParticipant = (participant: Participant) => { + const addParticipant = useCallback((participant: Participant) => { onSelectionChange([...selectedParticipants, participant]); setSearchQuery(''); + setHighlightedIndex(0); setIsDropdownOpen(false); + // Re-focus input after selection + requestAnimationFrame(() => inputRef.current?.focus()); + }, [onSelectionChange, selectedParticipants]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isDropdownOpen || filteredParticipants.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightedIndex((prev) => + prev < filteredParticipants.length - 1 ? prev + 1 : 0 + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredParticipants.length - 1 + ); + } else if (e.key === 'Enter') { + e.preventDefault(); + addParticipant(filteredParticipants[highlightedIndex]); + } else if (e.key === 'Escape') { + setIsDropdownOpen(false); + } }; const removeParticipant = (participantId: string) => { onSelectionChange(selectedParticipants.filter((p) => p.id !== participantId)); + setIsDropdownOpen(false); + inputRef.current?.blur(); }; const getInitials = (name: string) => { @@ -45,27 +84,34 @@ export const ParticipantSelector = ({ }; return ( -
+
{ setSearchQuery(e.target.value); + setHighlightedIndex(0); setIsDropdownOpen(true); }} onFocus={() => setIsDropdownOpen(true)} + onKeyDown={handleKeyDown} className="pl-10 h-12 bg-background border-border" /> {isDropdownOpen && filteredParticipants.length > 0 && (
- {filteredParticipants.map((participant) => ( + {filteredParticipants.map((participant, index) => (