import { compactDateTime, normalizeShiftBounds, parseDateTimeMs, MINUTE_MS, DAY_MS, wallTimeDayStartMs, formatAxisDayLabel } from './datetime.mjs'; import { entityKey, factKey, displayLabel } from './identity.mjs'; // Derives the visible day-aligned timeline axis from the current shift rows. function buildScheduleAxis(rows) { const timedRows = (rows || []).filter((row) => row.startMs != null && row.endMs != null); if (!timedRows.length) { return { horizonStartMs: 0, horizonEndMs: DAY_MS, horizonMinutes: DAY_MS / MINUTE_MS, columns: [{ label: 'Schedule', startMs: 0, endMs: DAY_MS }], }; } const minStartMs = timedRows.reduce((min, row) => Math.min(min, row.startMs), timedRows[0].startMs); const maxEndMs = timedRows.reduce((max, row) => Math.max(max, row.endMs), timedRows[0].endMs); const displayEndMs = maxEndMs > minStartMs ? maxEndMs - 1 : maxEndMs; const horizonStartMs = wallTimeDayStartMs(minStartMs); const horizonEndMs = wallTimeDayStartMs(displayEndMs) + DAY_MS; const dayCount = Math.max(1, Math.round((horizonEndMs - horizonStartMs) / DAY_MS)); const columns = []; for (let dayIndex = 0; dayIndex < dayCount; dayIndex += 1) { const columnStartMs = horizonStartMs + (dayIndex * DAY_MS); columns.push({ label: formatAxisDayLabel(columnStartMs), startMs: columnStartMs, endMs: columnStartMs + DAY_MS, }); } return { horizonStartMs, horizonEndMs, horizonMinutes: Math.max(1, Math.round((horizonEndMs - horizonStartMs) / MINUTE_MS)), columns, }; } // Consistent ordering shared by grouping and rendering. export function compareShiftRows(a, b) { if (a.startSortKey !== b.startSortKey) { return String(a.startSortKey).localeCompare(String(b.startSortKey)); } if (a.endSortKey !== b.endSortKey) { return String(a.endSortKey).localeCompare(String(b.endSortKey)); } if (a.locationLabel !== b.locationLabel) { return String(a.locationLabel).localeCompare(String(b.locationLabel)); } return String(a.shiftKey).localeCompare(String(b.shiftKey)); } // Converts raw transport data into render-friendly rows plus a shared axis model. export function buildShiftPresentation(shifts = [], employees = [], variableField = 'employeeIdx') { const employeeByIndex = {}; employees.forEach((employee, index) => { employeeByIndex[index] = employee; }); let rows = shifts.map((shift, shiftIndex) => { const employeeIndex = shift[variableField]; const employee = employeeIndex == null ? null : employeeByIndex[employeeIndex] || null; const bounds = normalizeShiftBounds(parseDateTimeMs(shift.start), parseDateTimeMs(shift.end)); const startLabel = compactDateTime(shift.start); const endLabel = compactDateTime(shift.end); const timeLabel = startLabel && endLabel ? `${startLabel} → ${endLabel}` : startLabel || endLabel || 'Unscheduled'; return { shift, shiftIndex, shiftKey: entityKey(shift, shiftIndex), employee, employeeIndex: employee == null ? null : employeeIndex, employeeKey: employee == null ? null : factKey(employee, employeeIndex), employeeLabel: employee == null ? 'Unassigned' : displayLabel(employee, employeeIndex), isAssigned: employee != null, locationLabel: shift.location || 'Unspecified location', requiredSkill: shift.requiredSkill || '', startLabel, endLabel, startMs: bounds.startMs, endMs: bounds.endMs, startSortKey: shift.start || '', endSortKey: shift.end || '', timeLabel, }; }); const axis = buildScheduleAxis(rows); rows = rows.map((row) => { const startOffsetMinutes = row.startMs == null ? 0 : Math.max(0, Math.round((row.startMs - axis.horizonStartMs) / MINUTE_MS)); const endOffsetMinutes = row.endMs == null ? startOffsetMinutes + 1 : Math.max(startOffsetMinutes + 1, Math.round((row.endMs - axis.horizonStartMs) / MINUTE_MS)); const clampedEndOffsetMinutes = Math.min(axis.horizonMinutes, endOffsetMinutes); return { ...row, startOffsetMinutes, endOffsetMinutes: Math.max(startOffsetMinutes + 1, clampedEndOffsetMinutes), durationMinutes: Math.max(1, clampedEndOffsetMinutes - startOffsetMinutes), }; }).sort(compareShiftRows); return { rows, unassignedCount: rows.reduce((count, row) => count + (row.isAssigned ? 0 : 1), 0), axis, }; }