Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions packages/client/src/components/AddTimeBlockModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface AddTimeBlockModalProps {
userRole: UserRole;
onClose: () => void;
onCreated: () => Promise<void>;
initialStartTime?: string;
}

interface EngineerOption {
Expand Down Expand Up @@ -141,7 +142,8 @@ export function AddTimeBlockModal({
project,
userRole,
onClose,
onCreated
onCreated,
initialStartTime
}: AddTimeBlockModalProps): JSX.Element {
const { showToast } = useToast();
const { timeZone } = useTimezone();
Expand All @@ -151,11 +153,24 @@ export function AddTimeBlockModal({
const defaultMonth = Number(defaultMonthPart || 1);
const defaultTime = '09:00';

const [selectedDates, setSelectedDates] = useState<string[]>([defaultDate]);
const [viewMonthStart, setViewMonthStart] = useState(
() => new Date(defaultYear, defaultMonth - 1, 1)
);
const [startTime, setStartTime] = useState(defaultTime);
const prefill = useMemo(() => {
if (!initialStartTime) return null;
const startDate = new Date(initialStartTime);
const dateKey = formatDateKey(startDate);
const hours = startDate.getHours().toString().padStart(2, '0');
const minutes = startDate.getMinutes().toString().padStart(2, '0');
return { dateKey, timeValue: `${hours}:${minutes}` };
}, [initialStartTime]);

const [selectedDates, setSelectedDates] = useState<string[]>([prefill?.dateKey ?? defaultDate]);
const [viewMonthStart, setViewMonthStart] = useState(() => {
if (prefill) {
const [yearPart, monthPart] = prefill.dateKey.split('-');
return new Date(Number(yearPart || 1970), Number(monthPart || 1) - 1, 1);
}
return new Date(defaultYear, defaultMonth - 1, 1);
});
const [startTime, setStartTime] = useState(prefill?.timeValue ?? defaultTime);
const [slotLengthMinutes, setSlotLengthMinutes] = useState(() =>
slotLengthOptions.includes(project.session_length_minutes) ? project.session_length_minutes : 60
);
Expand Down
169 changes: 169 additions & 0 deletions packages/client/src/components/AvailabilitySolverModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react';
import { CalendarSearch, Plus, AlertTriangle, Loader } from 'lucide-react';

import type {
AvailabilitySolverResponse,
AvailabilitySuggestion,
ProjectDetail
} from '@opencalendar/shared';

import { apiFetch } from '../api/client.js';
import { useTimezone } from '../context/TimezoneContext.js';

interface AvailabilitySolverModalProps {
project: ProjectDetail;
onClose: () => void;
onCreateBlock: (startTime: string) => void;
}

export function AvailabilitySolverModal({
project,
onClose,
onCreateBlock
}: AvailabilitySolverModalProps): JSX.Element {
const { timeZone } = useTimezone();

const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<AvailabilitySolverResponse | null>(null);

useEffect(() => {
let cancelled = false;

async function fetchSuggestions(): Promise<void> {
setLoading(true);
setError(null);

try {
const result = await apiFetch<AvailabilitySolverResponse>(
`/projects/${project.id}/availability-solver`
);
if (!cancelled) {
setData(result);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load suggestions');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}

void fetchSuggestions();

return () => {
cancelled = true;
};
}, [project.id]);

function formatSlotDate(suggestion: AvailabilitySuggestion): string {
const date = new Date(suggestion.start_time);
return date.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
timeZone
});
}

function formatSlotTime(suggestion: AvailabilitySuggestion): string {
const startDate = new Date(suggestion.start_time);
const endDate = new Date(suggestion.end_time);
const start = startDate.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
timeZone
});
const end = endDate.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
timeZone
});
return `${start} – ${end}`;
}

return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Availability Solver">
<div className="modal-card solver-modal">
<div className="solver-header">
<CalendarSearch size={20} />
<h3>Find a Time for Everyone</h3>
</div>

<p className="hint solver-description">
Searches calendars for the next available {project.session_length_minutes}-minute windows where
all assigned engineers are free.
</p>

{loading ? (
<div className="solver-loading">
<Loader size={24} className="spinner" />
<p>Checking team calendars&hellip;</p>
</div>
) : error ? (
<div className="solver-error">
<p className="error">{error}</p>
<button type="button" className="secondary-button" onClick={onClose}>
Close
</button>
</div>
) : data ? (
<>
{data.engineers_without_calendar.length > 0 ? (
<div className="solver-warning">
<AlertTriangle size={16} />
<span>
{data.engineers_without_calendar.length === 1
? `${data.engineers_without_calendar[0]} has`
: `${data.engineers_without_calendar.length} engineers have`}{' '}
no linked calendar — shown as available.
</span>
</div>
) : null}

{data.suggestions.length === 0 ? (
<div className="solver-empty">
<p className="hint">No open windows found in the next two weeks.</p>
<p className="hint">Try adjusting engineer assignments or extending the date range.</p>
</div>
) : (
<div className="solver-list">
{data.suggestions.map((suggestion) => (
<div
key={suggestion.start_time}
className="solver-suggestion"
>
<div className="solver-suggestion-info">
<span className="solver-suggestion-date">{formatSlotDate(suggestion)}</span>
<span className="solver-suggestion-time">{formatSlotTime(suggestion)}</span>
<span className="solver-suggestion-engineers">
{suggestion.available_engineers.length} engineer{suggestion.available_engineers.length !== 1 ? 's' : ''} available
</span>
</div>
<button
type="button"
className="solver-create-button"
onClick={() => onCreateBlock(suggestion.start_time)}
title="Create time block from this suggestion"
>
<Plus size={14} /> Create Block
</button>
</div>
))}
</div>
)}
</>
) : null}

<div className="solver-actions">
<button type="button" className="secondary-button" onClick={onClose}>
Close
</button>
</div>
</div>
</div>
);
}
63 changes: 52 additions & 11 deletions packages/client/src/pages/ProjectDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { CalendarX, ClipboardCopy, Inbox, Pencil, Plus, Save, Trash2 } from 'lucide-react';
import { CalendarX, ClipboardCopy, Inbox, Pencil, Plus, Save, Trash2, CalendarSearch } from 'lucide-react';

import type {
Booking,
Expand All @@ -14,6 +14,7 @@ import type {

import { apiFetch } from '../api/client.js';
import { AddTimeBlockModal } from '../components/AddTimeBlockModal.js';
import { AvailabilitySolverModal } from '../components/AvailabilitySolverModal.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { TimeZoneSelect } from '../components/TimeZoneSelect.js';
import { useAuth } from '../context/AuthContext.js';
Expand Down Expand Up @@ -149,6 +150,8 @@ export function ProjectDetailPage(): JSX.Element {
const [blockDeletePendingId, setBlockDeletePendingId] = useState<number | null>(null);
const [copyMessage, setCopyMessage] = useState<string | null>(null);
const [isAddTimeBlockModalOpen, setIsAddTimeBlockModalOpen] = useState(false);
const [isSolverModalOpen, setIsSolverModalOpen] = useState(false);
const [solverPrefill, setSolverPrefill] = useState<{ startTime: string } | null>(null);
const [editBlockState, setEditBlockState] = useState<EditTimeBlockState | null>(null);
const [editPending, setEditPending] = useState(false);
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
Expand All @@ -159,6 +162,17 @@ export function ProjectDetailPage(): JSX.Element {
user && (user.role === 'pm' || user.role === 'admin' || user.role === 'engineer')
);

const uniqueEngineerCount = useMemo(() => {
if (!project) return 0;
const ids = new Set<number>();
for (const block of project.time_blocks) {
for (const eng of block.engineers) {
ids.add(eng.id);
}
}
return ids.size;
}, [project]);

const loadProject = useCallback(async () => {
if (!id) {
setError('Missing project id');
Expand Down Expand Up @@ -604,15 +618,26 @@ export function ProjectDetailPage(): JSX.Element {
<h3>Time Blocks</h3>
<p className="hint">Displayed in {timeZone}</p>
</div>
{canCreateBlocks ? (
<button
type="button"
className="header-button"
onClick={() => setIsAddTimeBlockModalOpen(true)}
>
<Plus size={16} /> {isPm ? 'Add Time Blocks' : 'Add Personal Block'}
</button>
) : null}
<div className="header-actions">
{uniqueEngineerCount >= 2 && isPm ? (
<button
type="button"
className="header-button solver-trigger-button"
onClick={() => setIsSolverModalOpen(true)}
>
<CalendarSearch size={16} /> Find a Time for Everyone
</button>
) : null}
{canCreateBlocks ? (
<button
type="button"
className="header-button"
onClick={() => setIsAddTimeBlockModalOpen(true)}
>
<Plus size={16} /> {isPm ? 'Add Time Blocks' : 'Add Personal Block'}
</button>
) : null}
</div>
</div>
<TimeZoneSelect label="Display Timezone" />

Expand Down Expand Up @@ -739,8 +764,24 @@ export function ProjectDetailPage(): JSX.Element {
<AddTimeBlockModal
project={project}
userRole={user.role}
onClose={() => setIsAddTimeBlockModalOpen(false)}
onClose={() => {
setIsAddTimeBlockModalOpen(false);
setSolverPrefill(null);
}}
onCreated={loadProject}
initialStartTime={solverPrefill?.startTime}
/>
) : null}

{isSolverModalOpen ? (
<AvailabilitySolverModal
project={project}
onClose={() => setIsSolverModalOpen(false)}
onCreateBlock={(startTime) => {
setIsSolverModalOpen(false);
setSolverPrefill({ startTime });
setIsAddTimeBlockModalOpen(true);
}}
/>
) : null}

Expand Down
Loading
Loading