diff --git a/backend/alembic/versions/46a2e388b20a_add_timezone_to_participant.py b/backend/alembic/versions/46a2e388b20a_add_timezone_to_participant.py new file mode 100644 index 0000000..a1d9e98 --- /dev/null +++ b/backend/alembic/versions/46a2e388b20a_add_timezone_to_participant.py @@ -0,0 +1,29 @@ +"""add timezone to participant + +Revision ID: 46a2e388b20a +Revises: 001 +Create Date: 2026-01-28 18:48:09.141869 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '46a2e388b20a' +down_revision: Union[str, None] = '001' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('participants', sa.Column('timezone', sa.String(length=50), nullable=False, server_default='UTC')) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('participants', 'timezone') + # ### end Alembic commands ### diff --git a/backend/src/app/availability_service.py b/backend/src/app/availability_service.py index 9ef92bc..6f9981d 100644 --- a/backend/src/app/availability_service.py +++ b/backend/src/app/availability_service.py @@ -70,7 +70,7 @@ async def calculate_availability( participants = {p.id: p for p in participants_result.scalars().all()} days = ["Mon", "Tue", "Wed", "Thu", "Fri"] - hours = list(range(9, 18)) + hours = list(range(0, 24)) slots = [] for day_offset, day_name in enumerate(days): @@ -98,6 +98,7 @@ async def calculate_availability( slots.append({ "day": slot_start.strftime("%Y-%m-%d"), "hour": hour, + "start_time": slot_start, "availability": availability, "availableParticipants": available_participants, }) diff --git a/backend/src/app/main.py b/backend/src/app/main.py index 06a834c..9868424 100644 --- a/backend/src/app/main.py +++ b/backend/src/app/main.py @@ -15,6 +15,7 @@ from app.schemas import ( AvailabilityRequest, AvailabilityResponse, ParticipantCreate, + ParticipantUpdate, ParticipantResponse, SyncResponse, ScheduleRequest, @@ -63,6 +64,7 @@ async def create_participant( participant = Participant( name=data.name, email=data.email, + timezone=data.timezone, ics_url=data.ics_url, ) db.add(participant) @@ -95,6 +97,35 @@ async def get_participant(participant_id: UUID, db: AsyncSession = Depends(get_d return participant +@app.patch("/api/participants/{participant_id}", response_model=ParticipantResponse) +async def update_participant( + participant_id: UUID, data: ParticipantUpdate, db: AsyncSession = Depends(get_db) +): + result = await db.execute( + select(Participant).where(Participant.id == participant_id) + ) + participant = result.scalar_one_or_none() + if not participant: + raise HTTPException(status_code=404, detail="Participant not found") + + if data.timezone is not None: + participant.timezone = data.timezone + if data.ics_url is not None: + participant.ics_url = data.ics_url if data.ics_url else None + + await db.commit() + await db.refresh(participant) + + # Re-sync calendar if ICS URL was updated + if data.ics_url is not None and participant.ics_url: + try: + await sync_participant_calendar(db, participant) + except Exception as e: + logger.warning(f"Calendar sync failed for {participant.email}: {e}") + + return participant + + @app.delete("/api/participants/{participant_id}") async def delete_participant(participant_id: UUID, db: AsyncSession = Depends(get_db)): result = await db.execute( diff --git a/backend/src/app/models.py b/backend/src/app/models.py index 4f29c45..fd71f82 100644 --- a/backend/src/app/models.py +++ b/backend/src/app/models.py @@ -18,6 +18,7 @@ class Participant(Base): ) name: Mapped[str] = mapped_column(String(255), nullable=False) email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + timezone: Mapped[str] = mapped_column(String(50), nullable=False, default="America/Toronto") ics_url: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, nullable=False diff --git a/backend/src/app/schemas.py b/backend/src/app/schemas.py index bf31033..41c15df 100644 --- a/backend/src/app/schemas.py +++ b/backend/src/app/schemas.py @@ -7,6 +7,12 @@ from pydantic import BaseModel, EmailStr class ParticipantCreate(BaseModel): name: str email: EmailStr + timezone: str = "America/Toronto" + ics_url: str | None = None + + +class ParticipantUpdate(BaseModel): + timezone: str | None = None ics_url: str | None = None @@ -14,6 +20,7 @@ class ParticipantResponse(BaseModel): id: UUID name: str email: str + timezone: str ics_url: str | None created_at: datetime updated_at: datetime @@ -25,6 +32,7 @@ class ParticipantResponse(BaseModel): class TimeSlot(BaseModel): day: str hour: int + start_time: datetime availability: str availableParticipants: list[str] diff --git a/frontend/public/fonts/inter-400.woff2 b/frontend/public/fonts/inter-400.woff2 new file mode 100644 index 0000000..f15b025 Binary files /dev/null and b/frontend/public/fonts/inter-400.woff2 differ diff --git a/frontend/public/fonts/inter-500.woff2 b/frontend/public/fonts/inter-500.woff2 new file mode 100644 index 0000000..54f0a59 Binary files /dev/null and b/frontend/public/fonts/inter-500.woff2 differ diff --git a/frontend/public/fonts/inter-600.woff2 b/frontend/public/fonts/inter-600.woff2 new file mode 100644 index 0000000..d189794 Binary files /dev/null and b/frontend/public/fonts/inter-600.woff2 differ diff --git a/frontend/public/fonts/source-serif-pro-400.woff2 b/frontend/public/fonts/source-serif-pro-400.woff2 new file mode 100644 index 0000000..c40a3c1 Binary files /dev/null and b/frontend/public/fonts/source-serif-pro-400.woff2 differ diff --git a/frontend/public/fonts/source-serif-pro-600.woff2 b/frontend/public/fonts/source-serif-pro-600.woff2 new file mode 100644 index 0000000..d9ac6a0 Binary files /dev/null and b/frontend/public/fonts/source-serif-pro-600.woff2 differ diff --git a/frontend/public/fonts/source-serif-pro-700.woff2 b/frontend/public/fonts/source-serif-pro-700.woff2 new file mode 100644 index 0000000..34c9f8b Binary files /dev/null and b/frontend/public/fonts/source-serif-pro-700.woff2 differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18daf2e..ef7695e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,8 @@ const App = () => ( } /> + } /> + } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 450dfbb..1e1c9ab 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -5,6 +5,7 @@ export interface ParticipantAPI { id: string; name: string; email: string; + timezone: string; ics_url: string | null; created_at: string; updated_at: string; @@ -13,6 +14,7 @@ export interface ParticipantAPI { export interface TimeSlotAPI { day: string; hour: number; + start_time: string; availability: 'full' | 'partial' | 'none'; availableParticipants: string[]; } @@ -20,6 +22,12 @@ export interface TimeSlotAPI { export interface CreateParticipantRequest { name: string; email: string; + timezone: string; + ics_url?: string; +} + +export interface UpdateParticipantRequest { + timezone?: string; ics_url?: string; } @@ -45,6 +53,15 @@ export async function createParticipant(data: CreateParticipantRequest): Promise return handleResponse(response); } +export async function updateParticipant(id: string, data: UpdateParticipantRequest): Promise { + const response = await fetch(`${API_URL}/api/participants/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return handleResponse(response); +} + export async function deleteParticipant(id: string): Promise { const response = await fetch(`${API_URL}/api/participants/${id}`, { method: 'DELETE', diff --git a/frontend/src/components/AvailabilityHeatmap.tsx b/frontend/src/components/AvailabilityHeatmap.tsx index 504a29a..399ff88 100644 --- a/frontend/src/components/AvailabilityHeatmap.tsx +++ b/frontend/src/components/AvailabilityHeatmap.tsx @@ -8,23 +8,71 @@ import { import { Button } from '@/components/ui/button'; import { Check, X, Loader2 } 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 -const getWeekDates = () => { +// Get the dates for Mon-Fri of the current week in a specific timezone +const getWeekDates = (timezone: string): string[] => { + // Get "now" in the target timezone const now = new Date(); - const monday = new Date(now); - monday.setDate(now.getDate() - now.getDay() + 1); - monday.setHours(0, 0, 0, 0); + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + + // Parse today's date in the target timezone + const todayStr = formatter.format(now); + const [year, month, day] = todayStr.split('-').map(Number); + + // Calculate Monday of this week + const todayDate = new Date(year, month - 1, day); + const dayOfWeek = todayDate.getDay(); + const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; + + const mondayDate = new Date(year, month - 1, day + daysToMonday); return dayNames.map((_, i) => { - const date = new Date(monday); - date.setDate(monday.getDate() + i); - return date.toISOString().split('T')[0]; // "YYYY-MM-DD" + const d = new Date(mondayDate); + d.setDate(mondayDate.getDate() + i); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${dd}`; }); }; +// Convert a date string and hour in a timezone to a UTC Date +const toUTCDate = (dateStr: string, hour: number, timezone: string): Date => { + // Create a date string that represents the given hour in the given timezone + // Then parse it to get the UTC equivalent + const localDateStr = `${dateStr}T${String(hour).padStart(2, '0')}:00:00`; + + // Use a trick: format in UTC then in target TZ to find the offset + const testDate = new Date(localDateStr + 'Z'); // Treat as UTC first + + // Get what hour this would be in the target timezone + const tzHour = parseInt( + new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hour: 'numeric', + hour12: false, + }).format(testDate) + ); + + // Calculate offset in hours + const offset = tzHour - testDate.getUTCHours(); + + // Adjust: if we want `hour` in timezone, subtract the offset to get UTC + const utcDate = new Date(localDateStr + 'Z'); + utcDate.setUTCHours(utcDate.getUTCHours() - offset); + + return utcDate; +}; + interface AvailabilityHeatmapProps { slots: TimeSlot[]; selectedParticipants: Participant[]; @@ -40,10 +88,18 @@ export const AvailabilityHeatmap = ({ showPartialAvailability = false, isLoading = false, }: AvailabilityHeatmapProps) => { - const weekDates = getWeekDates(); + const weekDates = getWeekDates(TIMEZONE); - const getSlot = (dateStr: string, hour: number) => { - return slots.find((s) => s.day === dateStr && s.hour === hour); + // Find a slot that matches the given display timezone date/hour + const getSlot = (dateStr: string, hour: number): TimeSlot | undefined => { + // Convert display timezone date/hour to UTC + const targetUTC = toUTCDate(dateStr, hour, TIMEZONE); + + return slots.find((s) => { + const slotDate = new Date(s.start_time); + // Compare UTC timestamps (with some tolerance for rounding) + return Math.abs(slotDate.getTime() - targetUTC.getTime()) < 60000; // 1 minute tolerance + }); }; const getEffectiveAvailability = (slot: TimeSlot) => { @@ -58,23 +114,35 @@ export const AvailabilityHeatmap = ({ }; const isSlotTooSoon = (dateStr: string, hour: number) => { - const slotTime = new Date(`${dateStr}T${formatHour(hour)}:00Z`); + // Convert to UTC and compare with current time + const slotTimeUTC = toUTCDate(dateStr, hour, TIMEZONE); const now = new Date(); const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000); - return slotTime < twoHoursFromNow; + return slotTimeUTC < twoHoursFromNow; }; const getWeekDateRange = () => { - const now = new Date(); - const monday = new Date(now); - monday.setDate(now.getDate() - now.getDay() + 1); - const friday = new Date(monday); - friday.setDate(monday.getDate() + 4); + if (weekDates.length < 5) return ''; + const monday = new Date(weekDates[0] + 'T12:00:00Z'); + const friday = new Date(weekDates[4] + 'T12:00:00Z'); - const format = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + const format = (d: Date) => + d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }); return `${format(monday)} – ${format(friday)}`; }; + // Format hour for display in popover (in the display timezone) + const formatDisplayTime = (hour: number) => { + // Create a date at that hour + const date = new Date(); + date.setHours(hour, 0, 0, 0); + return new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }).format(date); + }; + if (selectedParticipants.length === 0) { return (
@@ -97,25 +165,30 @@ export const AvailabilityHeatmap = ({ return (
-
-

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

-

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

+
+
+

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

+

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

+
- {dayNames.map((dayName) => ( + {dayNames.map((dayName, i) => (
- {dayName} +
{dayName}
+
+ {weekDates[i]?.slice(5).replace('-', '/')} +
))}
@@ -151,7 +224,7 @@ export const AvailabilityHeatmap = ({
- {dayName} {formatHour(hour)}–{formatHour(hour + 1)} + {dayName} {formatDisplayTime(hour)}–{formatDisplayTime(hour + 1)}
{tooSoon && (
@@ -161,6 +234,7 @@ export const AvailabilityHeatmap = ({
{selectedParticipants.map((participant) => { const isAvailable = slot.availableParticipants.includes(participant.name); + return (
- {participant.name.split(' ')[0]} {isAvailable ? 'free' : 'busy'} + {participant.name.split(' ')[0]}
); diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 1b1a5c4..7aa9bb2 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,4 +1,5 @@ import { Calendar } from 'lucide-react'; +import { getAvatarColor } from '@/lib/utils'; export const Header = () => { return ( @@ -14,7 +15,10 @@ export const Header = () => {
-
+
AR
diff --git a/frontend/src/components/ParticipantManager.tsx b/frontend/src/components/ParticipantManager.tsx index 9cb9c53..058b032 100644 --- a/frontend/src/components/ParticipantManager.tsx +++ b/frontend/src/components/ParticipantManager.tsx @@ -3,23 +3,32 @@ import { Participant } from '@/types/calendar'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; -import { UserPlus, Trash2, User } from 'lucide-react'; +import { UserPlus, Trash2, User, Pencil, Check, X, AlertCircle } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; +import { getAvatarColor } from '@/lib/utils'; interface ParticipantManagerProps { participants: Participant[]; - onAddParticipant: (participant: { name: string; email: string; icsLink: string }) => void; + onAddParticipant: (participant: { name: string; email: string; timezone: string; icsLink: string }) => void; onRemoveParticipant: (id: string) => void; + onUpdateParticipant?: (id: string, data: { timezone?: string; ics_url?: string }) => Promise; } export const ParticipantManager = ({ participants, onAddParticipant, onRemoveParticipant, + onUpdateParticipant, }: ParticipantManagerProps) => { const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [icsLink, setIcsLink] = useState(''); + + // Edit state + const [editingId, setEditingId] = useState(null); + const [editIcsLink, setEditIcsLink] = useState(''); + const [isUpdating, setIsUpdating] = useState(false); + const { toast } = useToast(); const handleSubmit = (e: React.FormEvent) => { @@ -34,7 +43,12 @@ export const ParticipantManager = ({ return; } - onAddParticipant({ name: name.trim(), email: email.trim(), icsLink: icsLink.trim() || '' }); + onAddParticipant({ + name: name.trim(), + email: email.trim(), + timezone: 'America/Toronto', + icsLink: icsLink.trim() || '' + }); setName(''); setEmail(''); setIcsLink(''); @@ -45,6 +59,40 @@ export const ParticipantManager = ({ }); }; + const startEditing = (participant: Participant) => { + setEditingId(participant.id); + setEditIcsLink(participant.icsLink || ''); + }; + + const cancelEditing = () => { + setEditingId(null); + setEditIcsLink(''); + }; + + const saveEditing = async (participantId: string) => { + if (!onUpdateParticipant) return; + + setIsUpdating(true); + try { + await onUpdateParticipant(participantId, { + ics_url: editIcsLink || undefined, + }); + toast({ + title: "Participant updated", + description: "Changes saved successfully", + }); + setEditingId(null); + } catch (error) { + toast({ + title: "Update failed", + description: error instanceof Error ? error.message : "Unknown error", + variant: "destructive", + }); + } finally { + setIsUpdating(false); + } + }; + const getInitials = (name: string) => { return name .split(' ') @@ -64,7 +112,7 @@ export const ParticipantManager = ({
-
+
- + setIcsLink(e.target.value)} className="bg-background" @@ -123,26 +171,106 @@ export const ParticipantManager = ({ {participants.map((participant) => (
-
-
- {getInitials(participant.name)} -
-
-
{participant.name}
-
{participant.email}
-
-
+ {editingId === participant.id ? ( + // Edit mode +
+
+
+ {getInitials(participant.name)} +
+
+
{participant.name}
+
{participant.email}
+
+
- +
+ + setEditIcsLink(e.target.value)} + placeholder="https://..." + className="bg-card" + /> +
+ +
+ + +
+
+ ) : ( + // View mode +
+
+
+ {getInitials(participant.name)} +
+
+
{participant.name}
+
+ {participant.email} + + {participant.icsLink ? ( + + ICS linked + + ) : ( + + + No calendar linked + + )} +
+
+
+ +
+ {onUpdateParticipant && ( + + )} + +
+
+ )}
))}
diff --git a/frontend/src/components/ParticipantSelector.tsx b/frontend/src/components/ParticipantSelector.tsx index 067eccb..b47e42c 100644 --- a/frontend/src/components/ParticipantSelector.tsx +++ b/frontend/src/components/ParticipantSelector.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { Participant } from '@/types/calendar'; import { Input } from '@/components/ui/input'; -import { X, Plus, Search, User } from 'lucide-react'; -import { cn } from '@/lib/utils'; +import { X, Plus, Search, AlertCircle } from 'lucide-react'; +import { cn, getAvatarColor } from '@/lib/utils'; interface ParticipantSelectorProps { participants: Participant[]; @@ -67,16 +67,20 @@ export const ParticipantSelector = ({ onClick={() => addParticipant(participant)} className="w-full px-4 py-3 flex items-center gap-3 hover:bg-accent transition-colors text-left" > -
+
{getInitials(participant.name)}
{participant.name}
{participant.email}
- {!participant.connected && ( - - Not connected + {!participant.icsLink && ( + + + No calendar )} @@ -96,10 +100,16 @@ export const ParticipantSelector = ({ )} style={{ animationDelay: `${index * 50}ms` }} > -
+
{getInitials(participant.name)}
{participant.name.split(' ')[0]} + {!participant.icsLink && ( + + )} + + {isDropdownOpen && ( +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-10 h-9 bg-background border-border" + autoFocus + /> +
+
+ +
+ {filteredTimezones.length === 0 ? ( +
+ No timezones found +
+ ) : ( + filteredTimezones.slice(0, 50).map((timezone) => { + const isSelected = timezone === value; + const offset = getTimezoneOffset(timezone); + const label = formatTimezoneLabel(timezone); + const isHovered = hoveredTimezone === timezone; + + return ( + + ); + }) + )} +
+ + {filteredTimezones.length > 50 && ( +
+ Showing 50 of {filteredTimezones.length} results +
+ )} +
+ )} +
+ ); +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index fe7c483..c92114a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,104 +1,119 @@ -@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap'); - @tailwind base; @tailwind components; @tailwind utilities; @layer base { + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/inter-400.woff2') format('woff2'); + } + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/inter-500.woff2') format('woff2'); + } + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('/fonts/inter-600.woff2') format('woff2'); + } + @font-face { + font-family: 'Source Serif Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/source-serif-pro-400.woff2') format('woff2'); + } + @font-face { + font-family: 'Source Serif Pro'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('/fonts/source-serif-pro-600.woff2') format('woff2'); + } + @font-face { + font-family: 'Source Serif Pro'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts/source-serif-pro-700.woff2') format('woff2'); + } + :root { - --background: 210 20% 98%; - --foreground: 222 47% 11%; + /* Greyhaven Colors converted to HSL */ + /* Light Mode Defaults */ + --background: 60 9% 93%; /* #F0F0EC */ + --foreground: 60 5% 8%; /* #161614 */ - --card: 0 0% 100%; - --card-foreground: 222 47% 11%; + --card: 60 9% 97%; /* #F9F9F7 */ + --card-foreground: 60 5% 8%; - --popover: 0 0% 100%; - --popover-foreground: 222 47% 11%; + --popover: 60 9% 97%; + --popover-foreground: 60 5% 8%; - --primary: 173 58% 39%; - --primary-foreground: 0 0% 100%; + --primary: 18 68% 51%; /* #D95E2A (RGB 217 94 42) */ + --primary-foreground: 60 9% 97%; - --secondary: 210 20% 96%; - --secondary-foreground: 222 47% 11%; + --secondary: 60 9% 93%; + --secondary-foreground: 60 3% 18%; /* #2F2F2C */ - --muted: 210 20% 94%; - --muted-foreground: 215 16% 47%; + --muted: 60 5% 84%; /* Darker than background for contrast */ + --muted-foreground: 60 2% 34%; /* #575753 */ - --accent: 173 58% 94%; - --accent-foreground: 173 58% 25%; + --accent: 60 9% 85%; /* #DDD7 */ + --accent-foreground: 60 5% 8%; - --destructive: 0 84% 60%; - --destructive-foreground: 0 0% 100%; + --destructive: 0 57% 45%; /* #B43232 (RGB 180 50 50) */ + --destructive-foreground: 60 9% 97%; - --border: 214 32% 91%; - --input: 214 32% 91%; - --ring: 173 58% 39%; + --border: 60 5% 77%; /* #C4C4BD (RGB 196 196 189) */ + --input: 60 5% 77%; + --ring: 18 68% 51%; - --radius: 0.75rem; + --radius: 0.375rem; - /* Custom colors for heatmap */ - --availability-full: 142 71% 45%; - --availability-partial: 48 96% 53%; - --availability-none: 215 16% 85%; + /* Custom colors for heatmap - Updated to match system tags */ + --availability-full: 142 76% 36%; /* Tag Green */ + --availability-partial: 25 80% 65%; /* Tag Orange */ + --availability-none: 0 0% 80%; /* Grey */ + + /* Tag Colors */ + --tag-orange: 25 80% 65%; + --tag-green: 142 76% 36%; + --tag-blue: 210 100% 60%; + --tag-purple: 270 70% 65%; + --tag-brown: 30 40% 50%; + + /* Typography */ + --font-serif: 'Source Serif Pro', Georgia, 'Times New Roman', serif; + --font-display: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; /* Shadows */ - --shadow-sm: 0 1px 2px 0 hsl(222 47% 11% / 0.05); - --shadow-md: 0 4px 6px -1px hsl(222 47% 11% / 0.1), 0 2px 4px -2px hsl(222 47% 11% / 0.1); - --shadow-lg: 0 10px 15px -3px hsl(222 47% 11% / 0.1), 0 4px 6px -4px hsl(222 47% 11% / 0.1); - --shadow-xl: 0 20px 25px -5px hsl(222 47% 11% / 0.1), 0 8px 10px -6px hsl(222 47% 11% / 0.1); - - --sidebar-background: 0 0% 98%; - --sidebar-foreground: 240 5.3% 26.1%; - --sidebar-primary: 240 5.9% 10%; - --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 240 4.8% 95.9%; - --sidebar-accent-foreground: 240 5.9% 10%; - --sidebar-border: 220 13% 91%; - --sidebar-ring: 217.2 91.2% 59.8%; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); + + --sidebar-background: 60 9% 93%; + --sidebar-foreground: 60 5% 8%; + --sidebar-primary: 18 68% 51%; + --sidebar-primary-foreground: 60 9% 97%; + --sidebar-accent: 60 9% 85%; + --sidebar-accent-foreground: 60 5% 8%; + --sidebar-border: 60 5% 77%; + --sidebar-ring: 18 68% 51%; } - .dark { - --background: 222 47% 6%; - --foreground: 210 20% 98%; - - --card: 222 47% 8%; - --card-foreground: 210 20% 98%; - - --popover: 222 47% 8%; - --popover-foreground: 210 20% 98%; - - --primary: 173 58% 45%; - --primary-foreground: 0 0% 100%; - - --secondary: 222 47% 14%; - --secondary-foreground: 210 20% 98%; - - --muted: 222 47% 14%; - --muted-foreground: 215 16% 65%; - - --accent: 173 58% 15%; - --accent-foreground: 173 58% 75%; - - --destructive: 0 62% 30%; - --destructive-foreground: 210 20% 98%; - - --border: 222 47% 17%; - --input: 222 47% 17%; - --ring: 173 58% 45%; - - --availability-full: 142 71% 35%; - --availability-partial: 48 96% 40%; - --availability-none: 222 47% 20%; - - --sidebar-background: 240 5.9% 10%; - --sidebar-foreground: 240 4.8% 95.9%; - --sidebar-primary: 224.3 76.3% 48%; - --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 240 3.7% 15.9%; - --sidebar-accent-foreground: 240 4.8% 95.9%; - --sidebar-border: 240 3.7% 15.9%; - --sidebar-ring: 217.2 91.2% 59.8%; - } + /* Dark mode intentionally removed/reset to match light mode system for now, + or you can define a proper dark mode if required. + Keeping it simple as per previous apps. */ } @layer base { @@ -107,8 +122,12 @@ } body { - @apply bg-background text-foreground font-sans antialiased; - font-family: 'DM Sans', sans-serif; + @apply bg-background text-foreground antialiased; + font-family: var(--font-display); + } + + h1, h2, h3, h4, h5, h6 { + font-family: var(--font-serif); } } diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index a5ef193..d48fdfd 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -4,3 +4,18 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function getAvatarColor(name?: string): string { + const colors = [ + 'hsl(var(--tag-green))', + 'hsl(var(--tag-blue))', + 'hsl(var(--tag-orange))', + 'hsl(var(--tag-purple))', + 'hsl(var(--tag-brown))', + ]; + + if (!name) return colors[0]; + + const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + return colors[hash % colors.length]; +} diff --git a/frontend/src/pages/Index.tsx b/frontend/src/pages/Index.tsx index 30997fc..b1f86ef 100644 --- a/frontend/src/pages/Index.tsx +++ b/frontend/src/pages/Index.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; import { Header } from '@/components/Header'; import { ParticipantSelector } from '@/components/ParticipantSelector'; import { ParticipantManager } from '@/components/ParticipantManager'; @@ -19,6 +20,7 @@ import { useToast } from '@/hooks/use-toast'; import { fetchParticipants, createParticipant, + updateParticipant, deleteParticipant, fetchAvailability, syncCalendars, @@ -40,12 +42,20 @@ function apiToParticipant(p: ParticipantAPI): Participant { id: p.id, name: p.name, email: p.email, + timezone: p.timezone, icsLink: p.ics_url, connected: true, }; } -const Index = () => { +interface IndexProps { + defaultTab?: string; +} + +const Index = ({ defaultTab = 'schedule' }: IndexProps) => { + const navigate = useNavigate(); + const location = useLocation(); + const [activeTab, setActiveTab] = useState(defaultTab); const [participants, setParticipants] = useState([]); const [selectedParticipants, setSelectedParticipants] = useState([]); const [availabilitySlots, setAvailabilitySlots] = useState([]); @@ -56,6 +66,16 @@ const Index = () => { const [isSyncing, setIsSyncing] = useState(false); const { toast } = useToast(); + useEffect(() => { + // Sync internal state if prop changes (e.g. browser back button) + setActiveTab(defaultTab); + }, [defaultTab]); + + const handleTabChange = (value: string) => { + setActiveTab(value); + navigate(`/${value}`); + }; + useEffect(() => { const stored = localStorage.getItem(SETTINGS_KEY); if (stored) { @@ -113,11 +133,12 @@ const Index = () => { } }; - const handleAddParticipant = async (data: { name: string; email: string; icsLink: string }) => { + const handleAddParticipant = async (data: { name: string; email: string; timezone: string; icsLink: string }) => { try { const created = await createParticipant({ name: data.name, email: data.email, + timezone: data.timezone, ics_url: data.icsLink || undefined, }); setParticipants((prev) => [...prev, apiToParticipant(created)]); @@ -151,6 +172,16 @@ const Index = () => { } }; + const handleUpdateParticipant = async (id: string, data: { timezone?: string; ics_url?: string }) => { + const updated = await updateParticipant(id, data); + setParticipants((prev) => + prev.map((p) => (p.id === id ? apiToParticipant(updated) : p)) + ); + setSelectedParticipants((prev) => + prev.map((p) => (p.id === id ? apiToParticipant(updated) : p)) + ); + }; + const handleSyncCalendars = async () => { setIsSyncing(true); try { @@ -183,22 +214,28 @@ const Index = () => {
- - - + + + - Participants + People - + Schedule - +

- Manage Participants + Manage People

Add team members with their calendar ICS links @@ -209,6 +246,7 @@ const Index = () => { participants={participants} onAddParticipant={handleAddParticipant} onRemoveParticipant={handleRemoveParticipant} + onUpdateParticipant={handleUpdateParticipant} /> @@ -265,7 +303,7 @@ const Index = () => {

No participants yet

- Add participants in the Participants tab to start scheduling. + Add people in the People tab to start scheduling.

) : ( diff --git a/frontend/src/types/calendar.ts b/frontend/src/types/calendar.ts index bffd829..22cf95e 100644 --- a/frontend/src/types/calendar.ts +++ b/frontend/src/types/calendar.ts @@ -2,6 +2,7 @@ export interface Participant { id: string; name: string; email: string; + timezone: string; icsLink?: string; avatar?: string; connected: boolean; @@ -10,6 +11,7 @@ export interface Participant { export interface TimeSlot { day: string; hour: number; + start_time: string; availability: 'full' | 'partial' | 'none'; availableParticipants: string[]; } diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 979537d..44975f3 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -14,7 +14,8 @@ export default { }, extend: { fontFamily: { - sans: ['DM Sans', 'system-ui', 'sans-serif'], + sans: ['Inter', 'system-ui', 'sans-serif'], + serif: ['Source Serif Pro', 'Georgia', 'serif'], }, colors: { border: "hsl(var(--border))",