Files
scheduler/frontend/src/components/Calendar.jsx
T
2026-07-12 18:34:28 -04:00

179 lines
6.9 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import DayColumn from './DayColumn'
import { getDeviceAccentClass } from './TestCard'
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: [...] }, ... }
export default function Calendar({
scheduleData = {},
daytimeDateKey = null,
weekStart,
onWeekChange,
dualDeviceWeekendWeekEnabled = false,
onDualDeviceWeekendWeekEnabledChange,
windowLookup = new Map(),
onWindowSelect,
}) {
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)]
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)))
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>
<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>
</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
// 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)
return (
<DayColumn
key={key}
date={date}
isToday={isToday}
activeShifts={activeShifts}
shifts={shifts}
showShift2={isWeekend || (daytimeDateKey !== null && key === daytimeDateKey)}
windowLookup={windowLookup}
onWindowSelect={onWindowSelect}
/>
)
})}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs text-gray-400">
<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>
<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>
{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>
))}
</div>
</div>
)
}