/** * Jobs List Screen * * Displays jobs in a timeline view with day/week toggle. */ import { useCallback, useState, useRef, useMemo } from 'react'; import { View, Text, ScrollView, TouchableOpacity, StyleSheet, RefreshControl, ActivityIndicator, Alert, Dimensions, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { getJobColor, jobStatusColors } from '../../src/api/jobs'; import { useAuth } from '../../src/hooks/useAuth'; import { useJobs } from '../../src/hooks/useJobs'; import type { JobListItem, JobStatus } from '../../src/types'; const HOUR_HEIGHT = 60; const SCREEN_WIDTH = Dimensions.get('window').width; const DAY_COLUMN_WIDTH = (SCREEN_WIDTH - 50) / 7; // For week view const DAY_VIEW_WIDTH = SCREEN_WIDTH - 70; // Width available for job blocks in day view type ViewMode = 'day' | 'week'; // Lane layout calculation for overlapping appointments interface JobWithLane extends JobListItem { laneIndex: number; totalLanes: number; } function calculateLaneLayout(jobs: JobListItem[]): JobWithLane[] { if (jobs.length === 0) return []; // Sort by start time const sortedJobs = [...jobs].sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime() ); // Find overlapping groups and assign lanes const result: JobWithLane[] = []; const laneEndTimes: number[] = []; // First pass: assign lane indices const jobsWithLanes = sortedJobs.map((job) => { const startTime = new Date(job.start_time).getTime(); const endTime = new Date(job.end_time).getTime(); // Find the first available lane let laneIndex = 0; while (laneIndex < laneEndTimes.length && laneEndTimes[laneIndex] > startTime) { laneIndex++; } // Assign end time to this lane laneEndTimes[laneIndex] = endTime; return { ...job, laneIndex, totalLanes: 1 }; }); // Second pass: find overlapping groups and set totalLanes // Group overlapping jobs together const groups: JobWithLane[][] = []; let currentGroup: JobWithLane[] = []; let groupEndTime = 0; jobsWithLanes.forEach((job) => { const startTime = new Date(job.start_time).getTime(); const endTime = new Date(job.end_time).getTime(); if (currentGroup.length === 0 || startTime < groupEndTime) { // Job overlaps with current group currentGroup.push(job); groupEndTime = Math.max(groupEndTime, endTime); } else { // Start a new group if (currentGroup.length > 0) { groups.push(currentGroup); } currentGroup = [job]; groupEndTime = endTime; } }); if (currentGroup.length > 0) { groups.push(currentGroup); } // Set totalLanes for each job in its group groups.forEach((group) => { const maxLane = Math.max(...group.map(j => j.laneIndex)) + 1; group.forEach((job) => { job.totalLanes = maxLane; result.push(job); }); }); return result; } function formatTime(dateString: string): string { const date = new Date(dateString); return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, }); } function getHourLabel(hour: number): string { if (hour === 0) return '12 AM'; if (hour === 12) return '12 PM'; if (hour < 12) return `${hour} AM`; return `${hour - 12} PM`; } function isSameDay(date1: Date, date2: Date): boolean { // Compare using local date parts return ( date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate() ); } function parseJobDate(dateString: string): Date { // Parse ISO date string and return a Date object return new Date(dateString); } function getWeekDays(baseDate: Date): Date[] { const days: Date[] = []; const startOfWeek = new Date(baseDate); startOfWeek.setDate(baseDate.getDate() - baseDate.getDay()); // Start on Sunday for (let i = 0; i < 7; i++) { const day = new Date(startOfWeek); day.setDate(startOfWeek.getDate() + i); days.push(day); } return days; } function JobBlock({ job, onPress, viewMode, dayIndex = 0, laneIndex = 0, totalLanes = 1, }: { job: JobListItem; onPress: () => void; viewMode: ViewMode; dayIndex?: number; laneIndex?: number; totalLanes?: number; }) { // Use time-aware color function (shows red for overdue, yellow for in-progress window, etc.) const statusColor = getJobColor(job); const startDate = new Date(job.start_time); const endDate = new Date(job.end_time); const startHour = startDate.getHours() + startDate.getMinutes() / 60; const endHour = endDate.getHours() + endDate.getMinutes() / 60; const top = startHour * HOUR_HEIGHT; const height = Math.max((endHour - startHour) * HOUR_HEIGHT, 40); // Calculate width and position based on lanes let blockStyle: { left: number; width: number }; if (viewMode === 'week') { // Week view: divide the day column by lanes const laneWidth = (DAY_COLUMN_WIDTH - 4) / totalLanes; blockStyle = { left: dayIndex * DAY_COLUMN_WIDTH + laneIndex * laneWidth, width: laneWidth - 2, }; } else { // Day view: divide the full width by lanes const laneWidth = DAY_VIEW_WIDTH / totalLanes; blockStyle = { left: laneIndex * laneWidth, width: laneWidth - 4, }; } return ( {formatTime(job.start_time)} {job.title} {viewMode === 'day' && job.customer_name && ( {job.customer_name} )} {viewMode === 'day' && job.address && ( {job.address} )} ); } function TimelineGrid({ viewMode }: { viewMode: ViewMode }) { const hours = []; for (let hour = 0; hour < 24; hour++) { hours.push(hour); } return ( {hours.map((hour) => ( {getHourLabel(hour)} ))} ); } function WeekHeader({ weekDays, selectedDate }: { weekDays: Date[]; selectedDate: Date }) { const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const today = new Date(); return ( {weekDays.map((day, index) => { const isToday = isSameDay(day, today); const isSelected = isSameDay(day, selectedDate); return ( {dayNames[index]} {day.getDate()} ); })} ); } function CurrentTimeLine({ viewMode }: { viewMode: ViewMode }) { const now = new Date(); const currentHour = now.getHours() + now.getMinutes() / 60; const top = currentHour * HOUR_HEIGHT; return ( ); } export default function JobsScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); const { user, logout } = useAuth(); const [refreshing, setRefreshing] = useState(false); const [viewMode, setViewMode] = useState('day'); const [selectedDate, setSelectedDate] = useState(new Date()); const scrollRef = useRef(null); const weekDays = useMemo(() => getWeekDays(selectedDate), [selectedDate]); // Calculate the date range to fetch based on view mode const jobsQueryParams = useMemo(() => { if (viewMode === 'day') { // For day view, fetch just that day const startOfDay = new Date(selectedDate); startOfDay.setHours(0, 0, 0, 0); const endOfDay = new Date(selectedDate); endOfDay.setHours(23, 59, 59, 999); return { startDate: startOfDay.toISOString(), endDate: endOfDay.toISOString(), }; } else { // For week view, fetch the entire week const weekStart = new Date(weekDays[0]); weekStart.setHours(0, 0, 0, 0); const weekEnd = new Date(weekDays[6]); weekEnd.setHours(23, 59, 59, 999); return { startDate: weekStart.toISOString(), endDate: weekEnd.toISOString(), }; } }, [selectedDate, viewMode, weekDays]); // Use the jobs hook with WebSocket integration for real-time updates const { data, isLoading, error, refresh, isConnected, } = useJobs(jobsQueryParams); const onRefresh = useCallback(async () => { setRefreshing(true); await refresh(); setRefreshing(false); }, [refresh]); const handleJobPress = (jobId: number) => { router.push(`/(auth)/job/${jobId}`); }; const handleLogout = () => { Alert.alert( 'Logout', 'Are you sure you want to logout?', [ { text: 'Cancel', style: 'cancel' }, { text: 'Logout', style: 'destructive', onPress: async () => { await logout(); router.replace('/login'); }, }, ] ); }; // Scroll to current time on mount const scrollToNow = useCallback(() => { const now = new Date(); const currentHour = now.getHours(); const scrollPosition = Math.max(0, (currentHour - 1) * HOUR_HEIGHT); scrollRef.current?.scrollTo({ y: scrollPosition, animated: false }); }, []); // Filter jobs based on view mode const filteredJobs = useMemo(() => { const jobs = data?.jobs || []; if (viewMode === 'day') { return jobs.filter(job => isSameDay(new Date(job.start_time), selectedDate)); } // Week view - return all jobs for the week const weekStart = weekDays[0]; const weekEnd = new Date(weekDays[6]); weekEnd.setHours(23, 59, 59, 999); return jobs.filter(job => { const jobDate = new Date(job.start_time); return jobDate >= weekStart && jobDate <= weekEnd; }); }, [data?.jobs, viewMode, selectedDate, weekDays]); // Calculate lane layout for day view (overlapping appointments) const dayJobsWithLanes = useMemo(() => { if (viewMode !== 'day') return []; return calculateLaneLayout(filteredJobs); }, [filteredJobs, viewMode]); // Group jobs by day for week view with lane layout const jobsByDay = useMemo(() => { if (viewMode !== 'week') return {}; const grouped: Record = {}; weekDays.forEach((_, index) => { grouped[index] = []; }); filteredJobs.forEach(job => { const jobDate = new Date(job.start_time); const dayIndex = weekDays.findIndex(day => isSameDay(day, jobDate)); if (dayIndex !== -1) { grouped[dayIndex].push(job); } }); // Calculate lane layout for each day Object.keys(grouped).forEach(key => { const dayIndex = parseInt(key); grouped[dayIndex] = calculateLaneLayout(grouped[dayIndex]); }); return grouped; }, [filteredJobs, viewMode, weekDays]); if (isLoading && !refreshing) { return ( Loading jobs... ); } if (error) { return ( Failed to load jobs Try Again ); } const today = selectedDate.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', }); return ( Hello, {user?.name || 'there'}! {today} {/* View Mode Toggle */} {/* Date Navigation */} { const newDate = new Date(selectedDate); if (viewMode === 'day') { newDate.setDate(newDate.getDate() - 1); } else { newDate.setDate(newDate.getDate() - 7); } setSelectedDate(newDate); }} > setSelectedDate(new Date())} > Today { const newDate = new Date(selectedDate); if (viewMode === 'day') { newDate.setDate(newDate.getDate() + 1); } else { newDate.setDate(newDate.getDate() + 7); } setSelectedDate(newDate); }} > setViewMode('day')} > Day setViewMode('week')} > Week {filteredJobs.length} job{filteredJobs.length !== 1 ? 's' : ''} {/* Week Header (only in week view) */} {viewMode === 'week' && ( )} } onLayout={scrollToNow} showsVerticalScrollIndicator={true} > {viewMode === 'day' && isSameDay(selectedDate, new Date()) && ( )} {viewMode === 'day' ? ( dayJobsWithLanes.map((job) => ( handleJobPress(job.id)} viewMode={viewMode} laneIndex={job.laneIndex} totalLanes={job.totalLanes} /> )) ) : ( Object.entries(jobsByDay).map(([dayIndex, jobs]) => jobs.map((job) => ( handleJobPress(job.id)} viewMode={viewMode} dayIndex={parseInt(dayIndex)} laneIndex={job.laneIndex} totalLanes={job.totalLanes} /> )) ) )} {filteredJobs.length === 0 && ( No Jobs Scheduled Pull down to refresh )} ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#f9fafb', }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', backgroundColor: '#2563eb', paddingHorizontal: 16, paddingVertical: 16, }, greeting: { fontSize: 20, fontWeight: '700', color: '#fff', }, dateText: { fontSize: 14, color: 'rgba(255,255,255,0.8)', marginTop: 2, }, logoutButton: { padding: 8, backgroundColor: 'rgba(255,255,255,0.2)', borderRadius: 8, }, dateNavContainer: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', backgroundColor: '#fff', paddingVertical: 8, gap: 16, borderBottomWidth: 1, borderBottomColor: '#e5e7eb', }, navButton: { padding: 8, borderRadius: 8, backgroundColor: '#f3f4f6', }, todayButton: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 8, backgroundColor: '#eff6ff', }, todayButtonText: { color: '#2563eb', fontWeight: '600', fontSize: 14, }, viewModeContainer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', backgroundColor: '#fff', paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#e5e7eb', }, viewModeToggle: { flexDirection: 'row', backgroundColor: '#f3f4f6', borderRadius: 8, padding: 2, }, viewModeButton: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, gap: 4, }, viewModeButtonActive: { backgroundColor: '#2563eb', }, viewModeText: { fontSize: 14, fontWeight: '500', color: '#6b7280', }, viewModeTextActive: { color: '#fff', }, jobsCount: { fontSize: 14, color: '#6b7280', }, weekHeader: { flexDirection: 'row', backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingVertical: 8, }, weekHeaderSpacer: { width: 50, }, weekDayHeader: { width: DAY_COLUMN_WIDTH, alignItems: 'center', }, weekDayName: { fontSize: 11, color: '#9ca3af', fontWeight: '500', }, weekDayNameToday: { color: '#2563eb', }, weekDayNumber: { width: 28, height: 28, borderRadius: 14, alignItems: 'center', justifyContent: 'center', marginTop: 2, }, weekDayNumberToday: { backgroundColor: '#2563eb', }, weekDayNumberText: { fontSize: 14, fontWeight: '600', color: '#374151', }, weekDayNumberTextToday: { color: '#fff', }, timeline: { flex: 1, }, timelineContent: { position: 'relative', minHeight: 24 * HOUR_HEIGHT, paddingBottom: 40, }, timelineGrid: { position: 'absolute', top: 0, left: 0, right: 0, }, hourRow: { flexDirection: 'row', alignItems: 'flex-start', height: HOUR_HEIGHT, }, hourLabel: { width: 50, fontSize: 11, color: '#9ca3af', textAlign: 'right', paddingRight: 8, paddingTop: 2, }, hourLine: { flex: 1, height: 1, backgroundColor: '#e5e7eb', marginTop: 8, }, currentTimeLine: { position: 'absolute', left: 42, right: 0, flexDirection: 'row', alignItems: 'center', zIndex: 10, }, currentTimeDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: '#ef4444', }, currentTimeLineBar: { flex: 1, height: 2, backgroundColor: '#ef4444', }, jobsContainer: { position: 'absolute', top: 0, left: 58, right: 12, }, jobsContainerWeek: { left: 50, right: 0, }, jobBlock: { position: 'absolute', backgroundColor: '#fff', borderRadius: 6, borderLeftWidth: 3, padding: 6, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2, overflow: 'hidden', }, jobBlockHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2, }, jobBlockTime: { fontSize: 10, color: '#6b7280', fontWeight: '500', }, jobBlockTimeSmall: { fontSize: 9, }, statusDot: { width: 6, height: 6, borderRadius: 3, }, jobBlockTitle: { fontSize: 13, fontWeight: '600', color: '#111827', marginBottom: 2, }, jobBlockTitleSmall: { fontSize: 10, }, jobBlockCustomer: { fontSize: 11, color: '#4b5563', }, jobBlockAddress: { fontSize: 10, color: '#9ca3af', marginTop: 2, }, centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24, backgroundColor: '#f9fafb', }, loadingText: { marginTop: 12, fontSize: 16, color: '#6b7280', }, errorText: { marginTop: 12, fontSize: 16, color: '#ef4444', textAlign: 'center', }, retryButton: { marginTop: 16, backgroundColor: '#2563eb', paddingHorizontal: 24, paddingVertical: 12, borderRadius: 8, }, retryButtonText: { color: '#fff', fontSize: 14, fontWeight: '600', }, emptyOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center', alignItems: 'center', }, emptyContainer: { alignItems: 'center', backgroundColor: 'rgba(255,255,255,0.95)', padding: 32, borderRadius: 16, }, emptyTitle: { fontSize: 18, fontWeight: '600', color: '#6b7280', marginTop: 12, }, emptyText: { fontSize: 14, color: '#9ca3af', marginTop: 4, }, });