Compare commits
5 Commits
f2142633d4
...
more-impro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
267c1747f4 | ||
| 9412d5c0a0 | |||
|
|
192b885149 | ||
| 10675b6846 | |||
|
|
e544872430 |
@@ -144,7 +144,8 @@ async def delete_participant(participant_id: UUID, db: AsyncSession = Depends(ge
|
|||||||
async def get_availability(
|
async def get_availability(
|
||||||
request: AvailabilityRequest, db: AsyncSession = Depends(get_db)
|
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}
|
return {"slots": slots}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class TimeSlot(BaseModel):
|
|||||||
|
|
||||||
class AvailabilityRequest(BaseModel):
|
class AvailabilityRequest(BaseModel):
|
||||||
participant_ids: list[UUID]
|
participant_ids: list[UUID]
|
||||||
|
week_offset: int = 0
|
||||||
|
|
||||||
|
|
||||||
class AvailabilityResponse(BaseModel):
|
class AvailabilityResponse(BaseModel):
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
labels:
|
||||||
|
- traefik.http.middlewares.authentik-auth@file
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
@@ -42,6 +44,8 @@ services:
|
|||||||
VITE_API_URL: ${VITE_API_URL:-}
|
VITE_API_URL: ${VITE_API_URL:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
labels:
|
||||||
|
- traefik.http.middlewares.authentik-auth@file
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
expose:
|
expose:
|
||||||
- '80'
|
- '80'
|
||||||
|
|||||||
@@ -71,11 +71,11 @@ export async function deleteParticipant(id: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAvailability(participantIds: string[]): Promise<TimeSlotAPI[]> {
|
export async function fetchAvailability(participantIds: string[], weekOffset: number = 0): Promise<TimeSlotAPI[]> {
|
||||||
const response = await fetch(`${API_URL}/api/availability`, {
|
const response = await fetch(`${API_URL}/api/availability`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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);
|
const data = await handleResponse<{ slots: TimeSlotAPI[] }>(response);
|
||||||
return data.slots;
|
return data.slots;
|
||||||
|
|||||||
@@ -6,15 +6,16 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover';
|
} from '@/components/ui/popover';
|
||||||
import { Button } from '@/components/ui/button';
|
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 TIMEZONE = 'America/Toronto';
|
||||||
|
|
||||||
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
|
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
|
||||||
const hours = [9, 10, 11, 12, 13, 14, 15, 16, 17];
|
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
|
// Get the dates for Mon-Fri of a week in a specific timezone, offset by N weeks
|
||||||
const getWeekDates = (timezone: string): string[] => {
|
const getWeekDates = (timezone: string, weekOffset: number = 0): string[] => {
|
||||||
// Get "now" in the target timezone
|
// Get "now" in the target timezone
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
@@ -33,7 +34,7 @@ const getWeekDates = (timezone: string): string[] => {
|
|||||||
const dayOfWeek = todayDate.getDay();
|
const dayOfWeek = todayDate.getDay();
|
||||||
const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
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) => {
|
return dayNames.map((_, i) => {
|
||||||
const d = new Date(mondayDate);
|
const d = new Date(mondayDate);
|
||||||
@@ -73,12 +74,18 @@ const toUTCDate = (dateStr: string, hour: number, timezone: string): Date => {
|
|||||||
return utcDate;
|
return utcDate;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MIN_WEEK_OFFSET = 0;
|
||||||
|
const DEFAULT_MAX_WEEK_OFFSET = 1;
|
||||||
|
const EXPANDED_MAX_WEEK_OFFSET = 4;
|
||||||
|
|
||||||
interface AvailabilityHeatmapProps {
|
interface AvailabilityHeatmapProps {
|
||||||
slots: TimeSlot[];
|
slots: TimeSlot[];
|
||||||
selectedParticipants: Participant[];
|
selectedParticipants: Participant[];
|
||||||
onSlotSelect: (slot: TimeSlot) => void;
|
onSlotSelect: (slot: TimeSlot) => void;
|
||||||
showPartialAvailability?: boolean;
|
showPartialAvailability?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
weekOffset?: number;
|
||||||
|
onWeekOffsetChange?: (offset: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AvailabilityHeatmap = ({
|
export const AvailabilityHeatmap = ({
|
||||||
@@ -87,8 +94,12 @@ export const AvailabilityHeatmap = ({
|
|||||||
onSlotSelect,
|
onSlotSelect,
|
||||||
showPartialAvailability = false,
|
showPartialAvailability = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
weekOffset = 0,
|
||||||
|
onWeekOffsetChange,
|
||||||
}: AvailabilityHeatmapProps) => {
|
}: 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
|
// Find a slot that matches the given display timezone date/hour
|
||||||
const getSlot = (dateStr: string, hour: number): TimeSlot | undefined => {
|
const getSlot = (dateStr: string, hour: number): TimeSlot | undefined => {
|
||||||
@@ -167,13 +178,67 @@ export const AvailabilityHeatmap = ({
|
|||||||
<div className="bg-card rounded-xl shadow-card p-6 animate-slide-up">
|
<div className="bg-card rounded-xl shadow-card p-6 animate-slide-up">
|
||||||
<div className="mb-6 flex justify-between items-start">
|
<div className="mb-6 flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-lg font-semibold text-foreground">
|
<h3 className="text-lg font-semibold text-foreground">
|
||||||
Common Availability — Week of {getWeekDateRange()}
|
Common Availability — Week of {getWeekDateRange()}
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{selectedParticipants.length} participant{selectedParticipants.length > 1 ? 's' : ''}: {selectedParticipants.map(p => p.name.split(' ')[0]).join(', ')}
|
{selectedParticipants.length} participant{selectedParticipants.length > 1 ? 's' : ''}: {selectedParticipants.map(p => p.name.split(' ')[0]).join(', ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{onWeekOffsetChange && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={weekOffset <= MIN_WEEK_OFFSET}
|
||||||
|
onClick={() => onWeekOffsetChange(weekOffset - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{weekOffset !== 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
onClick={() => onWeekOffsetChange(0)}
|
||||||
|
>
|
||||||
|
This week
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{weekOffset < maxWeekOffset ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => onWeekOffsetChange(weekOffset + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
) : !expanded ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs gap-1"
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
>
|
||||||
|
<ChevronsRight className="w-3.5 h-3.5" />
|
||||||
|
Look further ahead
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { Participant } from '@/types/calendar';
|
import { Participant } from '@/types/calendar';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { X, Plus, Search, AlertCircle } from 'lucide-react';
|
import { X, Plus, Search, AlertCircle } from 'lucide-react';
|
||||||
@@ -17,6 +17,19 @@ export const ParticipantSelector = ({
|
|||||||
}: ParticipantSelectorProps) => {
|
}: ParticipantSelectorProps) => {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(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(
|
const filteredParticipants = participants.filter(
|
||||||
(p) =>
|
(p) =>
|
||||||
@@ -25,14 +38,43 @@ export const ParticipantSelector = ({
|
|||||||
p.email.toLowerCase().includes(searchQuery.toLowerCase()))
|
p.email.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
);
|
);
|
||||||
|
|
||||||
const addParticipant = (participant: Participant) => {
|
const addParticipant = useCallback((participant: Participant) => {
|
||||||
onSelectionChange([...selectedParticipants, participant]);
|
onSelectionChange([...selectedParticipants, participant]);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
|
// Keep dropdown open for multi-select; clamp highlight to new list length
|
||||||
|
setHighlightedIndex((prev) => {
|
||||||
|
const newLength = filteredParticipants.length - 1;
|
||||||
|
return prev >= newLength ? Math.max(0, newLength - 1) : prev;
|
||||||
|
});
|
||||||
|
// Keep focus on input so user can continue selecting
|
||||||
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
|
}, [onSelectionChange, selectedParticipants, filteredParticipants.length]);
|
||||||
|
|
||||||
|
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);
|
setIsDropdownOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeParticipant = (participantId: string) => {
|
const removeParticipant = (participantId: string) => {
|
||||||
onSelectionChange(selectedParticipants.filter((p) => p.id !== participantId));
|
onSelectionChange(selectedParticipants.filter((p) => p.id !== participantId));
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getInitials = (name: string) => {
|
const getInitials = (name: string) => {
|
||||||
@@ -45,27 +87,34 @@ export const ParticipantSelector = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div ref={containerRef} className="space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
placeholder="Search people..."
|
placeholder="Search people..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearchQuery(e.target.value);
|
setSearchQuery(e.target.value);
|
||||||
|
setHighlightedIndex(0);
|
||||||
setIsDropdownOpen(true);
|
setIsDropdownOpen(true);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setIsDropdownOpen(true)}
|
onFocus={() => setIsDropdownOpen(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
className="pl-10 h-12 bg-background border-border"
|
className="pl-10 h-12 bg-background border-border"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isDropdownOpen && filteredParticipants.length > 0 && (
|
{isDropdownOpen && filteredParticipants.length > 0 && (
|
||||||
<div className="absolute z-10 w-full mt-2 bg-popover border border-border rounded-lg shadow-popover animate-scale-in overflow-hidden">
|
<div className="absolute z-10 w-full mt-2 bg-popover border border-border rounded-lg shadow-popover animate-scale-in overflow-hidden">
|
||||||
{filteredParticipants.map((participant) => (
|
{filteredParticipants.map((participant, index) => (
|
||||||
<button
|
<button
|
||||||
key={participant.id}
|
key={participant.id}
|
||||||
onClick={() => addParticipant(participant)}
|
onClick={() => addParticipant(participant)}
|
||||||
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-accent transition-colors text-left"
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-4 py-3 flex items-center gap-3 hover:bg-accent transition-colors text-left",
|
||||||
|
index === highlightedIndex && "bg-accent"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium text-white"
|
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium text-white"
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
|
|||||||
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null);
|
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [settings, setSettings] = useState<SettingsState>(defaultSettings);
|
const [settings, setSettings] = useState<SettingsState>(defaultSettings);
|
||||||
|
const [weekOffset, setWeekOffset] = useState(0);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -101,7 +102,7 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
|
|||||||
} else {
|
} else {
|
||||||
setAvailabilitySlots([]);
|
setAvailabilitySlots([]);
|
||||||
}
|
}
|
||||||
}, [selectedParticipants]);
|
}, [selectedParticipants, weekOffset]);
|
||||||
|
|
||||||
const loadParticipants = async () => {
|
const loadParticipants = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -120,7 +121,7 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const ids = selectedParticipants.map((p) => p.id);
|
const ids = selectedParticipants.map((p) => p.id);
|
||||||
const slots = await fetchAvailability(ids);
|
const slots = await fetchAvailability(ids, weekOffset);
|
||||||
setAvailabilitySlots(slots);
|
setAvailabilitySlots(slots);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
@@ -325,6 +326,8 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
|
|||||||
onSlotSelect={handleSlotSelect}
|
onSlotSelect={handleSlotSelect}
|
||||||
showPartialAvailability={settings.showPartialAvailability}
|
showPartialAvailability={settings.showPartialAvailability}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
weekOffset={weekOffset}
|
||||||
|
onWeekOffsetChange={setWeekOffset}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user