3 Commits

Author SHA1 Message Date
Joyce
192b885149 imporvements 2026-02-02 13:49:11 -05:00
10675b6846 Merge pull request 'update to use authentik' (#3) from implement-authentication into main
Reviewed-on: #3
2026-01-29 17:46:43 +00:00
Joyce
e544872430 update to use authentik 2026-01-29 12:46:01 -05:00
7 changed files with 138 additions and 18 deletions

View File

@@ -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}

View File

@@ -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):

View File

@@ -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'

View File

@@ -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;

View File

@@ -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">

View File

@@ -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,40 @@ 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('');
setHighlightedIndex(0);
setIsDropdownOpen(false); 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) => { 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 +84,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"

View File

@@ -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}
/> />
</> </>
)} )}