9 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
f2142633d4 Merge pull request 'improve timezone discovery' (#2) from fix-timezone-issue into main
Reviewed-on: #2
2026-01-28 20:35:27 +00:00
Joyce
117b28c2e9 improve timezone discovery 2026-01-28 15:31:30 -05:00
Joyce
880925f30d improve timezone discovery 2026-01-28 14:53:12 -05:00
daa0afaa25 Merge pull request 'cleanup-and-test' (#1) from cleanup-and-test into main
Reviewed-on: #1
2026-01-23 20:44:13 +00:00
Joyce
49dbc786e9 fix: use SYNC_DATABASE_URL env var for alembic migrations
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:24:41 -05:00
Joyce
922b6f31d1 add static serve 2026-01-21 16:36:40 -05:00
27 changed files with 917 additions and 178 deletions

View File

@@ -1,3 +1,4 @@
import os
from logging.config import fileConfig from logging.config import fileConfig
from alembic import context from alembic import context
@@ -9,6 +10,10 @@ config = context.config
fileConfig(config.config_file_name) fileConfig(config.config_file_name)
target_metadata = Base.metadata target_metadata = Base.metadata
# Use SYNC_DATABASE_URL env var if set, otherwise fall back to alembic.ini
if os.getenv("SYNC_DATABASE_URL"):
config.set_main_option("sqlalchemy.url", os.getenv("SYNC_DATABASE_URL"))
def run_migrations_offline(): def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url") url = config.get_main_option("sqlalchemy.url")

View File

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

View File

@@ -70,7 +70,7 @@ async def calculate_availability(
participants = {p.id: p for p in participants_result.scalars().all()} participants = {p.id: p for p in participants_result.scalars().all()}
days = ["Mon", "Tue", "Wed", "Thu", "Fri"] days = ["Mon", "Tue", "Wed", "Thu", "Fri"]
hours = list(range(9, 18)) hours = list(range(0, 24))
slots = [] slots = []
for day_offset, day_name in enumerate(days): for day_offset, day_name in enumerate(days):
@@ -98,6 +98,7 @@ async def calculate_availability(
slots.append({ slots.append({
"day": slot_start.strftime("%Y-%m-%d"), "day": slot_start.strftime("%Y-%m-%d"),
"hour": hour, "hour": hour,
"start_time": slot_start,
"availability": availability, "availability": availability,
"availableParticipants": available_participants, "availableParticipants": available_participants,
}) })

View File

@@ -15,6 +15,7 @@ from app.schemas import (
AvailabilityRequest, AvailabilityRequest,
AvailabilityResponse, AvailabilityResponse,
ParticipantCreate, ParticipantCreate,
ParticipantUpdate,
ParticipantResponse, ParticipantResponse,
SyncResponse, SyncResponse,
ScheduleRequest, ScheduleRequest,
@@ -63,6 +64,7 @@ async def create_participant(
participant = Participant( participant = Participant(
name=data.name, name=data.name,
email=data.email, email=data.email,
timezone=data.timezone,
ics_url=data.ics_url, ics_url=data.ics_url,
) )
db.add(participant) db.add(participant)
@@ -95,6 +97,35 @@ async def get_participant(participant_id: UUID, db: AsyncSession = Depends(get_d
return participant 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}") @app.delete("/api/participants/{participant_id}")
async def delete_participant(participant_id: UUID, db: AsyncSession = Depends(get_db)): async def delete_participant(participant_id: UUID, db: AsyncSession = Depends(get_db)):
result = await db.execute( result = await db.execute(
@@ -113,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

@@ -18,6 +18,7 @@ class Participant(Base):
) )
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) 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) ics_url: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, nullable=False DateTime, default=datetime.utcnow, nullable=False

View File

@@ -7,6 +7,12 @@ from pydantic import BaseModel, EmailStr
class ParticipantCreate(BaseModel): class ParticipantCreate(BaseModel):
name: str name: str
email: EmailStr email: EmailStr
timezone: str = "America/Toronto"
ics_url: str | None = None
class ParticipantUpdate(BaseModel):
timezone: str | None = None
ics_url: str | None = None ics_url: str | None = None
@@ -14,6 +20,7 @@ class ParticipantResponse(BaseModel):
id: UUID id: UUID
name: str name: str
email: str email: str
timezone: str
ics_url: str | None ics_url: str | None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -25,12 +32,14 @@ class ParticipantResponse(BaseModel):
class TimeSlot(BaseModel): class TimeSlot(BaseModel):
day: str day: str
hour: int hour: int
start_time: datetime
availability: str availability: str
availableParticipants: list[str] availableParticipants: list[str]
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,7 +44,11 @@ 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:
- '80'
volumes: volumes:
postgres_data: postgres_data:

View File

@@ -15,18 +15,14 @@ ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build RUN npm run build
# Production stage # Production stage
FROM caddy:alpine FROM nginx:alpine
# Copy built assets # Copy built assets
COPY --from=builder /app/dist /srv COPY --from=builder /app/dist /usr/share/nginx/html
# Caddyfile for SPA routing # Copy nginx config
RUN echo ':8080 { \ COPY nginx.conf /etc/nginx/conf.d/default.conf
root * /srv \
file_server \
try_files {path} /index.html \
}' > /etc/caddy/Caddyfile
EXPOSE 8080 EXPOSE 80
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"] CMD ["nginx", "-g", "daemon off;"]

19
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,19 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_comp_level 6;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -16,6 +16,8 @@ const App = () => (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<Index />} /> <Route path="/" element={<Index />} />
<Route path="/participants" element={<Index defaultTab="participants" />} />
<Route path="/schedule" element={<Index defaultTab="schedule" />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>

View File

@@ -5,6 +5,7 @@ export interface ParticipantAPI {
id: string; id: string;
name: string; name: string;
email: string; email: string;
timezone: string;
ics_url: string | null; ics_url: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -13,6 +14,7 @@ export interface ParticipantAPI {
export interface TimeSlotAPI { export interface TimeSlotAPI {
day: string; day: string;
hour: number; hour: number;
start_time: string;
availability: 'full' | 'partial' | 'none'; availability: 'full' | 'partial' | 'none';
availableParticipants: string[]; availableParticipants: string[];
} }
@@ -20,6 +22,12 @@ export interface TimeSlotAPI {
export interface CreateParticipantRequest { export interface CreateParticipantRequest {
name: string; name: string;
email: string; email: string;
timezone: string;
ics_url?: string;
}
export interface UpdateParticipantRequest {
timezone?: string;
ics_url?: string; ics_url?: string;
} }
@@ -45,6 +53,15 @@ export async function createParticipant(data: CreateParticipantRequest): Promise
return handleResponse<ParticipantAPI>(response); return handleResponse<ParticipantAPI>(response);
} }
export async function updateParticipant(id: string, data: UpdateParticipantRequest): Promise<ParticipantAPI> {
const response = await fetch(`${API_URL}/api/participants/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return handleResponse<ParticipantAPI>(response);
}
export async function deleteParticipant(id: string): Promise<void> { export async function deleteParticipant(id: string): Promise<void> {
const response = await fetch(`${API_URL}/api/participants/${id}`, { const response = await fetch(`${API_URL}/api/participants/${id}`, {
method: 'DELETE', method: 'DELETE',
@@ -54,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,31 +6,86 @@ 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 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 // Get the dates for Mon-Fri of a week in a specific timezone, offset by N weeks
const getWeekDates = () => { const getWeekDates = (timezone: string, weekOffset: number = 0): string[] => {
// Get "now" in the target timezone
const now = new Date(); const now = new Date();
const monday = new Date(now); const formatter = new Intl.DateTimeFormat('en-CA', {
monday.setDate(now.getDate() - now.getDay() + 1); timeZone: timezone,
monday.setHours(0, 0, 0, 0); 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 + weekOffset * 7);
return dayNames.map((_, i) => { return dayNames.map((_, i) => {
const date = new Date(monday); const d = new Date(mondayDate);
date.setDate(monday.getDate() + i); d.setDate(mondayDate.getDate() + i);
return date.toISOString().split('T')[0]; // "YYYY-MM-DD" 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;
};
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 = ({
@@ -39,11 +94,23 @@ export const AvailabilityHeatmap = ({
onSlotSelect, onSlotSelect,
showPartialAvailability = false, showPartialAvailability = false,
isLoading = false, isLoading = false,
weekOffset = 0,
onWeekOffsetChange,
}: AvailabilityHeatmapProps) => { }: AvailabilityHeatmapProps) => {
const weekDates = getWeekDates(); const [expanded, setExpanded] = useState(false);
const maxWeekOffset = expanded ? EXPANDED_MAX_WEEK_OFFSET : DEFAULT_MAX_WEEK_OFFSET;
const weekDates = getWeekDates(TIMEZONE, weekOffset);
const getSlot = (dateStr: string, hour: number) => { // Find a slot that matches the given display timezone date/hour
return slots.find((s) => s.day === dateStr && s.hour === 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) => { const getEffectiveAvailability = (slot: TimeSlot) => {
@@ -58,23 +125,35 @@ export const AvailabilityHeatmap = ({
}; };
const isSlotTooSoon = (dateStr: string, hour: number) => { 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 now = new Date();
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000); const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
return slotTime < twoHoursFromNow; return slotTimeUTC < twoHoursFromNow;
}; };
const getWeekDateRange = () => { const getWeekDateRange = () => {
const now = new Date(); if (weekDates.length < 5) return '';
const monday = new Date(now); const monday = new Date(weekDates[0] + 'T12:00:00Z');
monday.setDate(now.getDate() - now.getDay() + 1); const friday = new Date(weekDates[4] + 'T12:00:00Z');
const friday = new Date(monday);
friday.setDate(monday.getDate() + 4);
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)}`; 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) { if (selectedParticipants.length === 0) {
return ( return (
<div className="bg-card rounded-xl shadow-card p-8 text-center animate-fade-in"> <div className="bg-card rounded-xl shadow-card p-8 text-center animate-fade-in">
@@ -97,25 +176,84 @@ export const AvailabilityHeatmap = ({
return ( return (
<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"> <div className="mb-6 flex justify-between items-start">
<h3 className="text-lg font-semibold text-foreground"> <div>
Common Availability Week of {getWeekDateRange()} <div className="flex items-center gap-2">
</h3> <h3 className="text-lg font-semibold text-foreground">
<p className="text-sm text-muted-foreground mt-1"> Common Availability Week of {getWeekDateRange()}
{selectedParticipants.length} participant{selectedParticipants.length > 1 ? 's' : ''}: {selectedParticipants.map(p => p.name.split(' ')[0]).join(', ')} </h3>
</p> </div>
<p className="text-sm text-muted-foreground mt-1">
{selectedParticipants.length} participant{selectedParticipants.length > 1 ? 's' : ''}: {selectedParticipants.map(p => p.name.split(' ')[0]).join(', ')}
</p>
</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">
<div className="min-w-[600px]"> <div className="min-w-[600px]">
<div className="grid grid-cols-[60px_repeat(5,1fr)] gap-1 mb-2"> <div className="grid grid-cols-[60px_repeat(5,1fr)] gap-1 mb-2">
<div></div> <div></div>
{dayNames.map((dayName) => ( {dayNames.map((dayName, i) => (
<div <div
key={dayName} key={dayName}
className="text-center text-sm font-medium text-muted-foreground py-2" className="text-center text-sm font-medium text-muted-foreground py-2"
> >
{dayName} <div>{dayName}</div>
<div className="text-xs opacity-70">
{weekDates[i]?.slice(5).replace('-', '/')}
</div>
</div> </div>
))} ))}
</div> </div>
@@ -151,7 +289,7 @@ export const AvailabilityHeatmap = ({
<PopoverContent className="w-64 p-4 animate-scale-in" align="center"> <PopoverContent className="w-64 p-4 animate-scale-in" align="center">
<div className="space-y-3"> <div className="space-y-3">
<div className="font-semibold text-foreground"> <div className="font-semibold text-foreground">
{dayName} {formatHour(hour)}{formatHour(hour + 1)} {dayName} {formatDisplayTime(hour)}{formatDisplayTime(hour + 1)}
</div> </div>
{tooSoon && ( {tooSoon && (
<div className="text-sm text-muted-foreground italic"> <div className="text-sm text-muted-foreground italic">
@@ -161,6 +299,7 @@ export const AvailabilityHeatmap = ({
<div className="space-y-2"> <div className="space-y-2">
{selectedParticipants.map((participant) => { {selectedParticipants.map((participant) => {
const isAvailable = slot.availableParticipants.includes(participant.name); const isAvailable = slot.availableParticipants.includes(participant.name);
return ( return (
<div <div
key={participant.id} key={participant.id}
@@ -174,7 +313,7 @@ export const AvailabilityHeatmap = ({
<span className={cn( <span className={cn(
isAvailable ? "text-foreground" : "text-muted-foreground" isAvailable ? "text-foreground" : "text-muted-foreground"
)}> )}>
{participant.name.split(' ')[0]} {isAvailable ? 'free' : 'busy'} {participant.name.split(' ')[0]}
</span> </span>
</div> </div>
); );

View File

@@ -1,4 +1,5 @@
import { Calendar } from 'lucide-react'; import { Calendar } from 'lucide-react';
import { getAvatarColor } from '@/lib/utils';
export const Header = () => { export const Header = () => {
return ( return (
@@ -14,7 +15,10 @@ export const Header = () => {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-sm font-medium text-primary"> <div
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium text-white"
style={{ backgroundColor: getAvatarColor("AR") }}
>
AR AR
</div> </div>
</div> </div>

View File

@@ -3,23 +3,32 @@ import { Participant } from '@/types/calendar';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; 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 { useToast } from '@/hooks/use-toast';
import { getAvatarColor } from '@/lib/utils';
interface ParticipantManagerProps { interface ParticipantManagerProps {
participants: Participant[]; 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; onRemoveParticipant: (id: string) => void;
onUpdateParticipant?: (id: string, data: { timezone?: string; ics_url?: string }) => Promise<void>;
} }
export const ParticipantManager = ({ export const ParticipantManager = ({
participants, participants,
onAddParticipant, onAddParticipant,
onRemoveParticipant, onRemoveParticipant,
onUpdateParticipant,
}: ParticipantManagerProps) => { }: ParticipantManagerProps) => {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [icsLink, setIcsLink] = useState(''); const [icsLink, setIcsLink] = useState('');
// Edit state
const [editingId, setEditingId] = useState<string | null>(null);
const [editIcsLink, setEditIcsLink] = useState('');
const [isUpdating, setIsUpdating] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@@ -34,7 +43,12 @@ export const ParticipantManager = ({
return; 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(''); setName('');
setEmail(''); setEmail('');
setIcsLink(''); 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) => { const getInitials = (name: string) => {
return name return name
.split(' ') .split(' ')
@@ -64,7 +112,7 @@ export const ParticipantManager = ({
</h3> </h3>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Name</Label> <Label htmlFor="name">Name</Label>
<Input <Input
@@ -89,10 +137,10 @@ export const ParticipantManager = ({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="icsLink">Calendar ICS Link (optional)</Label> <Label htmlFor="icsLink">Calendar ICS Link</Label>
<Input <Input
id="icsLink" id="icsLink"
placeholder="https://calendar.google.com/..." placeholder="https://..."
value={icsLink} value={icsLink}
onChange={(e) => setIcsLink(e.target.value)} onChange={(e) => setIcsLink(e.target.value)}
className="bg-background" className="bg-background"
@@ -123,26 +171,106 @@ export const ParticipantManager = ({
{participants.map((participant) => ( {participants.map((participant) => (
<div <div
key={participant.id} key={participant.id}
className="flex items-center justify-between p-4 bg-background rounded-lg border border-border" className="p-4 bg-background rounded-lg border border-border"
> >
<div className="flex items-center gap-3"> {editingId === participant.id ? (
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-sm font-medium text-primary"> // Edit mode
{getInitials(participant.name)} <div className="space-y-4">
</div> <div className="flex items-center gap-3">
<div> <div
<div className="font-medium text-foreground">{participant.name}</div> className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium text-white"
<div className="text-sm text-muted-foreground">{participant.email}</div> style={{ backgroundColor: getAvatarColor(participant.name) }}
</div> >
</div> {getInitials(participant.name)}
</div>
<div>
<div className="font-medium text-foreground">{participant.name}</div>
<div className="text-sm text-muted-foreground">{participant.email}</div>
</div>
</div>
<Button <div className="space-y-2">
variant="ghost" <Label htmlFor={`edit-ics-${participant.id}`}>Calendar ICS Link</Label>
size="icon" <Input
onClick={() => onRemoveParticipant(participant.id)} id={`edit-ics-${participant.id}`}
className="text-muted-foreground hover:text-destructive" value={editIcsLink}
> onChange={(e) => setEditIcsLink(e.target.value)}
<Trash2 className="w-4 h-4" /> placeholder="https://..."
</Button> className="bg-card"
/>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => saveEditing(participant.id)}
disabled={isUpdating}
>
<Check className="w-4 h-4 mr-1" />
{isUpdating ? 'Saving...' : 'Save'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={cancelEditing}
disabled={isUpdating}
>
<X className="w-4 h-4 mr-1" />
Cancel
</Button>
</div>
</div>
) : (
// View mode
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium text-white"
style={{ backgroundColor: getAvatarColor(participant.name) }}
>
{getInitials(participant.name)}
</div>
<div>
<div className="font-medium text-foreground">{participant.name}</div>
<div className="text-sm text-muted-foreground flex flex-wrap gap-x-2">
<span>{participant.email}</span>
<span className="text-muted-foreground/60"></span>
{participant.icsLink ? (
<span className="text-primary truncate max-w-[200px]" title={participant.icsLink}>
ICS linked
</span>
) : (
<span className="text-amber-600 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
No calendar linked
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1">
{onUpdateParticipant && (
<Button
variant="ghost"
size="icon"
onClick={() => startEditing(participant)}
className="text-muted-foreground hover:text-foreground"
>
<Pencil className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => onRemoveParticipant(participant.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,8 +1,8 @@
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, User } from 'lucide-react'; import { X, Plus, Search, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn, getAvatarColor } from '@/lib/utils';
interface ParticipantSelectorProps { interface ParticipantSelectorProps {
participants: Participant[]; participants: Participant[];
@@ -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,38 +84,49 @@ 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 className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium text-primary"> <div
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium text-white"
style={{ backgroundColor: getAvatarColor(participant.name) }}
>
{getInitials(participant.name)} {getInitials(participant.name)}
</div> </div>
<div> <div>
<div className="font-medium text-foreground">{participant.name}</div> <div className="font-medium text-foreground">{participant.name}</div>
<div className="text-xs text-muted-foreground">{participant.email}</div> <div className="text-xs text-muted-foreground">{participant.email}</div>
</div> </div>
{!participant.connected && ( {!participant.icsLink && (
<span className="ml-auto text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded"> <span className="ml-auto text-xs text-amber-600 flex items-center gap-1">
Not connected <AlertCircle className="w-3 h-3" />
No calendar
</span> </span>
)} )}
</button> </button>
@@ -96,10 +146,16 @@ export const ParticipantSelector = ({
)} )}
style={{ animationDelay: `${index * 50}ms` }} style={{ animationDelay: `${index * 50}ms` }}
> >
<div className="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-xs font-medium"> <div
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium text-white"
style={{ backgroundColor: getAvatarColor(participant.name) }}
>
{getInitials(participant.name)} {getInitials(participant.name)}
</div> </div>
<span className="font-medium">{participant.name.split(' ')[0]}</span> <span className="font-medium">{participant.name.split(' ')[0]}</span>
{!participant.icsLink && (
<AlertCircle className="w-3 h-3 text-amber-600" title="No calendar linked" />
)}
<button <button
onClick={() => removeParticipant(participant.id)} onClick={() => removeParticipant(participant.id)}
className="w-5 h-5 rounded-full hover:bg-primary/20 flex items-center justify-center transition-colors" className="w-5 h-5 rounded-full hover:bg-primary/20 flex items-center justify-center transition-colors"

View File

@@ -0,0 +1,217 @@
import { useState, useEffect, useRef } from 'react';
import { Input } from '@/components/ui/input';
import { Search, Globe, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
interface TimezoneSelectorProps {
value: string;
onChange: (timezone: string) => void;
className?: string;
}
// Get all IANA timezones
const getAllTimezones = (): string[] => {
try {
return Intl.supportedValuesOf('timeZone');
} catch {
// Fallback for older browsers
return [
'UTC',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Toronto',
'America/Vancouver',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Asia/Tokyo',
'Asia/Shanghai',
'Asia/Singapore',
'Australia/Sydney',
'Pacific/Auckland',
];
}
};
// Get UTC offset for a timezone
const getTimezoneOffset = (timezone: string): string => {
try {
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'shortOffset',
});
const parts = formatter.formatToParts(now);
const offsetPart = parts.find((p) => p.type === 'timeZoneName');
return offsetPart?.value || '';
} catch {
return '';
}
};
// Get current time in a timezone
const getCurrentTimeInTimezone = (timezone: string): string => {
try {
return new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour: 'numeric',
minute: '2-digit',
hour12: true,
}).format(new Date());
} catch {
return '';
}
};
// Format timezone for display (e.g., "America/New_York" -> "New York")
const formatTimezoneLabel = (timezone: string): string => {
const parts = timezone.split('/');
const city = parts[parts.length - 1];
return city.replace(/_/g, ' ');
};
const ALL_TIMEZONES = getAllTimezones();
export const TimezoneSelector = ({
value,
onChange,
className,
}: TimezoneSelectorProps) => {
const [searchQuery, setSearchQuery] = useState('');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [hoveredTimezone, setHoveredTimezone] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filteredTimezones = ALL_TIMEZONES.filter((tz) => {
const query = searchQuery.toLowerCase();
const tzLower = tz.toLowerCase();
const labelLower = formatTimezoneLabel(tz).toLowerCase();
const offset = getTimezoneOffset(tz).toLowerCase();
return (
tzLower.includes(query) ||
labelLower.includes(query) ||
offset.includes(query)
);
});
const selectTimezone = (timezone: string) => {
onChange(timezone);
setSearchQuery('');
setIsDropdownOpen(false);
};
const selectedOffset = getTimezoneOffset(value);
const selectedLabel = formatTimezoneLabel(value);
return (
<div ref={containerRef} className={cn('relative', className)}>
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm',
'bg-muted hover:bg-accent transition-colors',
'border border-transparent hover:border-border'
)}
>
<Globe className="w-4 h-4 text-muted-foreground" />
<span className="text-foreground font-medium">{selectedLabel}</span>
<span className="text-muted-foreground">{selectedOffset}</span>
<ChevronDown className={cn(
'w-4 h-4 text-muted-foreground transition-transform',
isDropdownOpen && 'rotate-180'
)} />
</button>
{isDropdownOpen && (
<div className="absolute z-20 right-0 mt-2 w-80 bg-popover border border-border rounded-lg shadow-popover animate-scale-in overflow-hidden">
<div className="p-2 border-b border-border">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search timezone..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 h-9 bg-background border-border"
autoFocus
/>
</div>
</div>
<div className="max-h-64 overflow-y-auto">
{filteredTimezones.length === 0 ? (
<div className="px-4 py-3 text-sm text-muted-foreground text-center">
No timezones found
</div>
) : (
filteredTimezones.slice(0, 50).map((timezone) => {
const isSelected = timezone === value;
const offset = getTimezoneOffset(timezone);
const label = formatTimezoneLabel(timezone);
const isHovered = hoveredTimezone === timezone;
return (
<button
key={timezone}
onClick={() => selectTimezone(timezone)}
onMouseEnter={() => setHoveredTimezone(timezone)}
onMouseLeave={() => setHoveredTimezone(null)}
className={cn(
'w-full px-4 py-2.5 flex items-center justify-between text-left transition-colors',
isSelected ? 'bg-primary/10 text-primary' : 'hover:bg-accent'
)}
>
<div className="flex items-center gap-3">
<span className={cn(
'text-xs font-mono w-16',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}>
{offset}
</span>
<div>
<div className={cn(
'font-medium',
isSelected ? 'text-primary' : 'text-foreground'
)}>
{label}
</div>
<div className="text-xs text-muted-foreground">
{timezone}
</div>
</div>
</div>
{isHovered && (
<span className="text-xs text-muted-foreground animate-fade-in">
{getCurrentTimeInTimezone(timezone)}
</span>
)}
</button>
);
})
)}
</div>
{filteredTimezones.length > 50 && (
<div className="px-4 py-2 text-xs text-muted-foreground text-center border-t border-border">
Showing 50 of {filteredTimezones.length} results
</div>
)}
</div>
)}
</div>
);
};

View File

@@ -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 base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @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 { :root {
--background: 210 20% 98%; /* Greyhaven Colors converted to HSL */
--foreground: 222 47% 11%; /* Light Mode Defaults */
--background: 60 9% 93%; /* #F0F0EC */
--foreground: 60 5% 8%; /* #161614 */
--card: 0 0% 100%; --card: 60 9% 97%; /* #F9F9F7 */
--card-foreground: 222 47% 11%; --card-foreground: 60 5% 8%;
--popover: 0 0% 100%; --popover: 60 9% 97%;
--popover-foreground: 222 47% 11%; --popover-foreground: 60 5% 8%;
--primary: 173 58% 39%; --primary: 18 68% 51%; /* #D95E2A (RGB 217 94 42) */
--primary-foreground: 0 0% 100%; --primary-foreground: 60 9% 97%;
--secondary: 210 20% 96%; --secondary: 60 9% 93%;
--secondary-foreground: 222 47% 11%; --secondary-foreground: 60 3% 18%; /* #2F2F2C */
--muted: 210 20% 94%; --muted: 60 5% 84%; /* Darker than background for contrast */
--muted-foreground: 215 16% 47%; --muted-foreground: 60 2% 34%; /* #575753 */
--accent: 173 58% 94%; --accent: 60 9% 85%; /* #DDD7 */
--accent-foreground: 173 58% 25%; --accent-foreground: 60 5% 8%;
--destructive: 0 84% 60%; --destructive: 0 57% 45%; /* #B43232 (RGB 180 50 50) */
--destructive-foreground: 0 0% 100%; --destructive-foreground: 60 9% 97%;
--border: 214 32% 91%; --border: 60 5% 77%; /* #C4C4BD (RGB 196 196 189) */
--input: 214 32% 91%; --input: 60 5% 77%;
--ring: 173 58% 39%; --ring: 18 68% 51%;
--radius: 0.75rem; --radius: 0.375rem;
/* Custom colors for heatmap */ /* Custom colors for heatmap - Updated to match system tags */
--availability-full: 142 71% 45%; --availability-full: 142 76% 36%; /* Tag Green */
--availability-partial: 48 96% 53%; --availability-partial: 25 80% 65%; /* Tag Orange */
--availability-none: 215 16% 85%; --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 */ /* Shadows */
--shadow-sm: 0 1px 2px 0 hsl(222 47% 11% / 0.05); --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 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-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 hsl(222 47% 11% / 0.1), 0 4px 6px -4px hsl(222 47% 11% / 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 hsl(222 47% 11% / 0.1), 0 8px 10px -6px hsl(222 47% 11% / 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: 0 0% 98%; --sidebar-background: 60 9% 93%;
--sidebar-foreground: 240 5.3% 26.1%; --sidebar-foreground: 60 5% 8%;
--sidebar-primary: 240 5.9% 10%; --sidebar-primary: 18 68% 51%;
--sidebar-primary-foreground: 0 0% 98%; --sidebar-primary-foreground: 60 9% 97%;
--sidebar-accent: 240 4.8% 95.9%; --sidebar-accent: 60 9% 85%;
--sidebar-accent-foreground: 240 5.9% 10%; --sidebar-accent-foreground: 60 5% 8%;
--sidebar-border: 220 13% 91%; --sidebar-border: 60 5% 77%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 18 68% 51%;
} }
.dark { /* Dark mode intentionally removed/reset to match light mode system for now,
--background: 222 47% 6%; or you can define a proper dark mode if required.
--foreground: 210 20% 98%; Keeping it simple as per previous apps. */
--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%;
}
} }
@layer base { @layer base {
@@ -107,8 +122,12 @@
} }
body { body {
@apply bg-background text-foreground font-sans antialiased; @apply bg-background text-foreground antialiased;
font-family: 'DM Sans', sans-serif; font-family: var(--font-display);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-serif);
} }
} }

View File

@@ -4,3 +4,18 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); 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];
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { ParticipantSelector } from '@/components/ParticipantSelector'; import { ParticipantSelector } from '@/components/ParticipantSelector';
import { ParticipantManager } from '@/components/ParticipantManager'; import { ParticipantManager } from '@/components/ParticipantManager';
@@ -19,6 +20,7 @@ import { useToast } from '@/hooks/use-toast';
import { import {
fetchParticipants, fetchParticipants,
createParticipant, createParticipant,
updateParticipant,
deleteParticipant, deleteParticipant,
fetchAvailability, fetchAvailability,
syncCalendars, syncCalendars,
@@ -40,22 +42,41 @@ function apiToParticipant(p: ParticipantAPI): Participant {
id: p.id, id: p.id,
name: p.name, name: p.name,
email: p.email, email: p.email,
timezone: p.timezone,
icsLink: p.ics_url, icsLink: p.ics_url,
connected: true, 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<Participant[]>([]); const [participants, setParticipants] = useState<Participant[]>([]);
const [selectedParticipants, setSelectedParticipants] = useState<Participant[]>([]); const [selectedParticipants, setSelectedParticipants] = useState<Participant[]>([]);
const [availabilitySlots, setAvailabilitySlots] = useState<TimeSlot[]>([]); const [availabilitySlots, setAvailabilitySlots] = useState<TimeSlot[]>([]);
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();
useEffect(() => {
// Sync internal state if prop changes (e.g. browser back button)
setActiveTab(defaultTab);
}, [defaultTab]);
const handleTabChange = (value: string) => {
setActiveTab(value);
navigate(`/${value}`);
};
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem(SETTINGS_KEY); const stored = localStorage.getItem(SETTINGS_KEY);
if (stored) { if (stored) {
@@ -81,7 +102,7 @@ const Index = () => {
} else { } else {
setAvailabilitySlots([]); setAvailabilitySlots([]);
} }
}, [selectedParticipants]); }, [selectedParticipants, weekOffset]);
const loadParticipants = async () => { const loadParticipants = async () => {
try { try {
@@ -100,7 +121,7 @@ const Index = () => {
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({
@@ -113,11 +134,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 { try {
const created = await createParticipant({ const created = await createParticipant({
name: data.name, name: data.name,
email: data.email, email: data.email,
timezone: data.timezone,
ics_url: data.icsLink || undefined, ics_url: data.icsLink || undefined,
}); });
setParticipants((prev) => [...prev, apiToParticipant(created)]); setParticipants((prev) => [...prev, apiToParticipant(created)]);
@@ -151,6 +173,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 () => { const handleSyncCalendars = async () => {
setIsSyncing(true); setIsSyncing(true);
try { try {
@@ -183,22 +215,28 @@ const Index = () => {
<Header /> <Header />
<main className="container max-w-5xl mx-auto px-4 py-8"> <main className="container max-w-5xl mx-auto px-4 py-8">
<Tabs defaultValue="schedule" className="space-y-6"> <Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full max-w-md mx-auto grid-cols-2"> <TabsList className="grid w-full max-w-md mx-auto grid-cols-2 bg-muted p-1 rounded-xl">
<TabsTrigger value="participants" className="flex items-center gap-2"> <TabsTrigger
value="participants"
className="flex items-center gap-2 rounded-lg data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm transition-all"
>
<Users className="w-4 h-4" /> <Users className="w-4 h-4" />
Participants People
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="schedule" className="flex items-center gap-2"> <TabsTrigger
value="schedule"
className="flex items-center gap-2 rounded-lg data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm transition-all"
>
<CalendarDays className="w-4 h-4" /> <CalendarDays className="w-4 h-4" />
Schedule Schedule
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="participants" className="animate-fade-in"> <TabsContent value="participants" className="animate-fade-in focus-visible:outline-none">
<div className="text-center mb-6"> <div className="text-center mb-6">
<h2 className="text-3xl font-bold text-foreground mb-2"> <h2 className="text-3xl font-bold text-foreground mb-2">
Manage Participants Manage People
</h2> </h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Add team members with their calendar ICS links Add team members with their calendar ICS links
@@ -209,6 +247,7 @@ const Index = () => {
participants={participants} participants={participants}
onAddParticipant={handleAddParticipant} onAddParticipant={handleAddParticipant}
onRemoveParticipant={handleRemoveParticipant} onRemoveParticipant={handleRemoveParticipant}
onUpdateParticipant={handleUpdateParticipant}
/> />
</TabsContent> </TabsContent>
@@ -265,7 +304,7 @@ const Index = () => {
<Users className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" /> <Users className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="text-lg font-medium text-foreground mb-2">No participants yet</h3> <h3 className="text-lg font-medium text-foreground mb-2">No participants yet</h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Add participants in the Participants tab to start scheduling. Add people in the People tab to start scheduling.
</p> </p>
</div> </div>
) : ( ) : (
@@ -287,6 +326,8 @@ const Index = () => {
onSlotSelect={handleSlotSelect} onSlotSelect={handleSlotSelect}
showPartialAvailability={settings.showPartialAvailability} showPartialAvailability={settings.showPartialAvailability}
isLoading={isLoading} isLoading={isLoading}
weekOffset={weekOffset}
onWeekOffsetChange={setWeekOffset}
/> />
</> </>
)} )}

View File

@@ -2,6 +2,7 @@ export interface Participant {
id: string; id: string;
name: string; name: string;
email: string; email: string;
timezone: string;
icsLink?: string; icsLink?: string;
avatar?: string; avatar?: string;
connected: boolean; connected: boolean;
@@ -10,6 +11,7 @@ export interface Participant {
export interface TimeSlot { export interface TimeSlot {
day: string; day: string;
hour: number; hour: number;
start_time: string;
availability: 'full' | 'partial' | 'none'; availability: 'full' | 'partial' | 'none';
availableParticipants: string[]; availableParticipants: string[];
} }

View File

@@ -14,7 +14,8 @@ export default {
}, },
extend: { extend: {
fontFamily: { fontFamily: {
sans: ['DM Sans', 'system-ui', 'sans-serif'], sans: ['Inter', 'system-ui', 'sans-serif'],
serif: ['Source Serif Pro', 'Georgia', 'serif'],
}, },
colors: { colors: {
border: "hsl(var(--border))", border: "hsl(var(--border))",