Files
scheduler/frontend/src/components/Calendar.jsx
T
2026-07-13 09:49:10 -04:00

252 lines
10 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 { useMemo } from 'react'
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 DEFAULT_SHIFT_MIN_ROWS = 3
const SLOT_CHROME_PX = 10
const TEST_CARD_ROW_HEIGHT_PX = 22
const TEST_CARD_ROW_GAP_PX = 2
const DAY_HEADER_HEIGHT_PX = 48
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 shiftMinRows = useMemo(() => {
const minima = {
shift2: DEFAULT_SHIFT_MIN_ROWS,
shift3: DEFAULT_SHIFT_MIN_ROWS,
nextDayShift1: DEFAULT_SHIFT_MIN_ROWS,
}
for (const date of orderedDays) {
const key = toDateKey(date)
const shifts = scheduleData[key] ?? { shift1: [], shift2: [], shift3: [] }
const nextKey = toDateKey(addDays(date, 1))
const nextDayShift1 = scheduleData[nextKey]?.shift1 ?? []
minima.shift2 = Math.max(minima.shift2, shifts.shift2.length || DEFAULT_SHIFT_MIN_ROWS)
minima.shift3 = Math.max(minima.shift3, shifts.shift3.length || DEFAULT_SHIFT_MIN_ROWS)
minima.nextDayShift1 = Math.max(minima.nextDayShift1, nextDayShift1.length || DEFAULT_SHIFT_MIN_ROWS)
}
return minima
}, [orderedDays, scheduleData])
const slotHeights = useMemo(() => {
const calcSlotHeight = (rows) => {
const safeRows = Math.max(DEFAULT_SHIFT_MIN_ROWS, rows || DEFAULT_SHIFT_MIN_ROWS)
const contentHeight = (safeRows * TEST_CARD_ROW_HEIGHT_PX) + ((safeRows - 1) * TEST_CARD_ROW_GAP_PX)
return contentHeight + SLOT_CHROME_PX
}
return {
shift2: calcSlotHeight(shiftMinRows.shift2),
shift3: calcSlotHeight(shiftMinRows.shift3),
nextDayShift1: calcSlotHeight(shiftMinRows.nextDayShift1),
}
}, [shiftMinRows])
const totalShiftHeightPx = slotHeights.shift2 + slotHeights.shift3 + slotHeights.nextDayShift1
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-2 flex-1 min-h-0 overflow-hidden">
<div className="w-10 shrink-0 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
<div style={{ height: `${DAY_HEADER_HEIGHT_PX}px` }} />
<div className="relative" style={{ height: `${totalShiftHeightPx}px` }}>
<span className="absolute left-0 top-0">9AM</span>
<span className="absolute left-0" style={{ top: `${slotHeights.shift2}px` }}>5PM</span>
<span className="absolute left-0" style={{ top: `${slotHeights.shift2 + slotHeights.shift3}px` }}>12AM</span>
<span
className="absolute right-2 text-[10px] font-bold uppercase tracking-[0.16em] text-gray-600"
style={{
top: `${slotHeights.shift2 + slotHeights.shift3 + (slotHeights.nextDayShift1 / 2)}px`,
transform: 'translateY(-50%) rotate(180deg)',
writingMode: 'vertical-rl',
textOrientation: 'mixed',
}}
>
Next Day
</span>
</div>
</div>
<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 ?? []
// 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
return (
<DayColumn
key={key}
date={date}
isToday={isToday}
activeShifts={activeShifts}
shifts={shifts}
nextDayShift1={nextDayShift1}
nextDayShift1Active={nextDayShift1Active}
shiftMinRows={shiftMinRows}
slotHeights={slotHeights}
showShift2={isWeekend || (daytimeDateKey !== null && key === daytimeDateKey)}
windowLookup={windowLookup}
onWindowSelect={onWindowSelect}
/>
)
})}
</div>
</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>
)
}