Files
scheduler/frontend/src/components/Calendar.jsx
T

187 lines
7.3 KiB
React
Raw Normal View History

2026-06-17 16:02:04 -04:00
import DayColumn from './DayColumn'
2026-07-12 14:19:58 -04:00
import { getDeviceAccentClass } from './TestCard'
2026-06-17 16:02:04 -04:00
function addDays(date, n) {
const d = new Date(date)
d.setDate(d.getDate() + n)
return d
}
function formatDateRange(monday) {
const sunday = addDays(monday, 6)
const opts = { month: 'short', day: 'numeric' }
const startStr = monday.toLocaleDateString('en-US', opts)
const endStr = sunday.toLocaleDateString('en-US', {
month: sunday.getMonth() !== monday.getMonth() ? 'short' : undefined,
day: 'numeric',
})
return `${startStr} ${endStr}`
}
function toDateKey(date) {
return date.toISOString().slice(0, 10)
}
function getMondayOfWeek(date) {
const d = new Date(date)
const day = d.getDay()
d.setDate(d.getDate() + (day === 0 ? -6 : 1 - day))
d.setHours(0, 0, 0, 0)
return d
}
// Returns the two shifts that form the next/active test window.
// Window = Shift 3 of day D → Shift 1 of day D+1
function getNextTestWindow() {
const now = new Date()
const hour = now.getHours()
const tod = new Date(now); tod.setHours(0, 0, 0, 0)
// midnight1AM: still inside shift 3 that started yesterday
if (hour < 1) {
return { shift3Date: toDateKey(addDays(tod, -1)), shift1Date: toDateKey(tod) }
}
// 1AM5PM: shift 1 finished or in daytime gap — next window starts at 5PM today
// 5PMmidnight: currently inside shift 3 of today
// Both cases: upcoming/active window is shift 3 today + shift 1 tomorrow
return { shift3Date: toDateKey(tod), shift1Date: toDateKey(addDays(tod, 1)) }
}
// scheduleData: { "YYYY-MM-DD": { shift1: [...], shift2: [...], shift3: [...] }, ... }
2026-07-12 14:19:58 -04:00
export default function Calendar({
scheduleData = {},
daytimeDateKey = null,
weekStart,
onWeekChange,
2026-07-12 18:34:28 -04:00
dualDeviceWeekendWeekEnabled = false,
onDualDeviceWeekendWeekEnabledChange,
2026-07-12 14:19:58 -04:00
windowLookup = new Map(),
onWindowSelect,
}) {
2026-06-17 16:02:04 -04:00
const today = new Date()
today.setHours(0, 0, 0, 0)
const activeWindow = getNextTestWindow()
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
// Reorder columns: Sun Mon Tue Wed Thu Fri Sat
// weekStart is Monday, so days[0]=Mon ... days[6]=Sun → put Sunday first
const orderedDays = [ ...days.slice(0, 7)]
2026-07-12 14:19:58 -04:00
const deviceLegendItems = Array.from(
new Set(
Object.values(scheduleData)
.flatMap((day) => [
...(day?.shift1 ?? []),
...(day?.shift2 ?? []),
...(day?.shift3 ?? []),
])
.map((test) => test?.device)
.filter(Boolean),
),
).sort((a, b) => String(a).localeCompare(String(b)))
2026-06-17 16:02:04 -04:00
return (
<div className="flex flex-col gap-3 h-full">
{/* Week navigation */}
<div className="flex items-center gap-3">
<button
onClick={() => onWeekChange(addDays(weekStart, -7))}
className="p-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Previous week"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-sm font-medium text-gray-300 min-w-36 text-center">
{formatDateRange(weekStart)}
</span>
<button
onClick={() => onWeekChange(addDays(weekStart, 7))}
className="p-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Next week"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<button
onClick={() => onWeekChange(getMondayOfWeek(new Date()))}
className="px-2 py-1 text-xs font-semibold text-gray-300 border border-gray-600 rounded hover:bg-gray-700 hover:text-white transition-colors"
title="Jump to this week"
>
Today
</button>
2026-07-12 18:34:28 -04:00
<div className="ml-auto flex items-center gap-2 text-xs text-gray-300 select-none">
<span>Run both devices on weekend this week</span>
<button
type="button"
role="switch"
aria-checked={dualDeviceWeekendWeekEnabled}
onClick={() => onDualDeviceWeekendWeekEnabledChange?.(!dualDeviceWeekendWeekEnabled)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/70 ${
dualDeviceWeekendWeekEnabled ? 'bg-blue-600' : 'bg-gray-600'
}`}
title="Toggle dual-device weekend-start scheduling for this week"
>
<span
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform ${
dualDeviceWeekendWeekEnabled ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
</div>
2026-06-17 16:02:04 -04:00
</div>
{/* 7-day grid */}
<div className="flex gap-1.5 flex-1 overflow-x-auto">
{orderedDays.map((date) => {
const key = toDateKey(date)
const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] }
const isToday = date.getTime() === today.getTime()
const isWeekend = date.getDay() === 0 || date.getDay() === 6
// Get next day's shift1 for display
const nextDate = addDays(date, 1)
const nextKey = toDateKey(nextDate)
const nextDayShift1 = scheduleData[nextKey]?.shift1 ?? []
2026-06-17 16:02:04 -04:00
// Which shifts on this date are part of the active test window?
const activeShifts = new Set()
if (key === activeWindow.shift3Date) activeShifts.add(3)
if (key === activeWindow.shift1Date) activeShifts.add(1)
// Check if next day's shift1 is active
const nextDayShift1Active = nextKey === activeWindow.shift1Date
2026-06-17 16:02:04 -04:00
return (
<DayColumn
key={key}
date={date}
isToday={isToday}
activeShifts={activeShifts}
shifts={shifts}
nextDayShift1={nextDayShift1}
nextDayShift1Active={nextDayShift1Active}
2026-06-17 16:02:04 -04:00
showShift2={isWeekend || (daytimeDateKey !== null && key === daytimeDateKey)}
2026-07-12 14:19:58 -04:00
windowLookup={windowLookup}
onWindowSelect={onWindowSelect}
2026-06-17 16:02:04 -04:00
/>
)
})}
</div>
{/* Legend */}
2026-07-12 14:19:58 -04:00
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs text-gray-400">
2026-06-17 16:02:04 -04:00
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-gray-500 inline-block" />Pending</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-green-700 inline-block" />Completed</span>
2026-06-25 11:31:20 -04:00
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-red-700 inline-block" />Rerun Required</span>
2026-07-12 14:19:58 -04:00
{deviceLegendItems.map((device) => (
<span key={device} className="flex items-center gap-1.5">
<span className={`w-2.5 h-2.5 rounded-sm inline-block ${getDeviceAccentClass(device)}`} />
{device}
</span>
))}
2026-06-17 16:02:04 -04:00
</div>
</div>
)
}