// ── Scheduling logic: timeline computation, route cache, job placement ─────── import { ref, computed } from 'vue' import { localDateStr, timeToH, hToTime, sortJobsByTime, jobSpansDate } from './useHelpers' export function useScheduler (store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColorFn) { const H_START = 7 const H_END = 20 // ── Route cache ──────────────────────────────────────────────────────────── const routeLegs = ref({}) const routeGeometry = ref({}) // ── Parent start position cache ──────────────────────────────────────────── let _parentStartCache = {} function getParentStartH (job) { if (!store.technicians.length) return job.startHour ?? 8 const key = `${job.assignedTech}||${job.id}` if (_parentStartCache[key] !== undefined) return _parentStartCache[key] const leadTech = store.technicians.find(t => t.id === job.assignedTech) if (!leadTech) return job.startHour ?? 8 const dayStr = localDateStr(periodStart.value) const leadJobs = sortJobsByTime(leadTech.queue.filter(j => getJobDate(j.id) === dayStr)) const cacheKey = `${leadTech.id}||${dayStr}` const legMins = routeLegs.value[cacheKey] const hasHome = !!(leadTech.coords?.[0] && leadTech.coords?.[1]) let cursor = 8, result = job.startHour ?? 8 leadJobs.forEach((j, idx) => { const showTravel = idx > 0 || (idx === 0 && hasHome) if (showTravel) { const legIdx = hasHome ? idx : idx - 1 const routeMin = legMins?.[legIdx] cursor += (routeMin != null ? routeMin : (parseFloat(j.legDur) > 0 ? parseFloat(j.legDur) : 20)) / 60 } const pinnedH = j.startTime ? timeToH(j.startTime) : null const startH = pinnedH ?? cursor if (j.id === job.id) result = startH cursor = startH + (parseFloat(j.duration) || 1) }) _parentStartCache[key] = result return result } // ── All jobs for a tech on a date (primary + assists) ────────────────────── function techAllJobsForDate (tech, dateStr) { _parentStartCache = {} const primary = tech.queue.filter(j => jobSpansDate(j, dateStr)) const assists = (tech.assistJobs || []) .filter(j => jobSpansDate(j, dateStr)) .map(j => { const a = j.assistants.find(x => x.techId === tech.id) const parentH = getParentStartH(j) return { ...j, duration: a?.duration || j.duration, startTime: hToTime(parentH), startHour: parentH, _isAssist: true, _assistPinned: !!a?.pinned, _assistNote: a?.note || '', _parentJob: j, } }) return sortJobsByTime([...primary, ...assists]) } // ── Day view: schedule blocks with pinned anchors + auto-flow ────────────── function techDayJobsWithTravel (tech) { const dayStr = localDateStr(periodStart.value) const cacheKey = `${tech.id}||${dayStr}` const legMins = routeLegs.value[cacheKey] const hasHome = !!(tech.coords?.[0] && tech.coords?.[1]) const allJobs = techAllJobsForDate(tech, dayStr) const flowEntries = [] const floatingEntries = [] allJobs.forEach(job => { const isAssist = !!job._isAssist const dur = parseFloat(job.duration) || 1 const isPinned = isAssist ? !!job._assistPinned : !!getJobTime(job.id) const pinH = isAssist ? job.startHour : (getJobTime(job.id) ? timeToH(getJobTime(job.id)) : null) const entry = { job, dur, isAssist, isPinned, pinH } if (isAssist && !job._assistPinned) floatingEntries.push(entry) else flowEntries.push(entry) }) const pinnedAnchors = flowEntries.filter(e => e.isPinned).map(e => ({ start: e.pinH, end: e.pinH + e.dur })) const placed = [] const occupied = pinnedAnchors.map(a => ({ ...a })) const sortedFlow = [...flowEntries].sort((a, b) => { if (a.isPinned && b.isPinned) return a.pinH - b.pinH if (a.isPinned) return -1 if (b.isPinned) return 1 return 0 }) sortedFlow.filter(e => e.isPinned).forEach(e => placed.push({ entry: e, startH: e.pinH })) let cursor = 8, flowIdx = 0 sortedFlow.filter(e => !e.isPinned).forEach(e => { const legIdx = hasHome ? flowIdx : flowIdx - 1 const routeMin = legMins?.[legIdx >= 0 ? legIdx : 0] const travelH = (routeMin != null ? routeMin : (parseFloat(e.job.legDur) > 0 ? parseFloat(e.job.legDur) : 20)) / 60 let startH = cursor + (flowIdx > 0 || hasHome ? travelH : 0) let safe = false while (!safe) { const endH = startH + e.dur const overlap = occupied.find(o => startH < o.end && endH > o.start) if (overlap) startH = overlap.end + travelH else safe = true } placed.push({ entry: e, startH }) occupied.push({ start: startH, end: startH + e.dur }) cursor = startH + e.dur flowIdx++ }) placed.sort((a, b) => a.startH - b.startH) const result = [] let prevEndH = null placed.forEach((p, pIdx) => { const { entry, startH } = p const { job, dur, isAssist, isPinned } = entry const realJob = isAssist ? job._parentJob : job const travelStart = prevEndH ?? (hasHome ? 8 : null) if (travelStart != null && startH > travelStart + 0.01) { const gapH = startH - travelStart const legIdx = hasHome ? pIdx : pIdx - 1 const routeMin = legMins?.[legIdx >= 0 ? legIdx : 0] const fromRoute = routeMin != null result.push({ type: 'travel', job: realJob, travelMin: fromRoute ? routeMin : Math.round(gapH * 60), fromRoute, isAssist: false, style: { left: (travelStart - H_START) * pxPerHr.value + 'px', width: Math.max(8, gapH * pxPerHr.value) + 'px', top: '10px', bottom: '10px', position: 'absolute' }, color: jobColorFn(realJob), }) } const jLeft = (startH - H_START) * pxPerHr.value const jWidth = Math.max(18, dur * pxPerHr.value) result.push({ type: isAssist ? 'assist' : 'job', job: realJob, pinned: isPinned, pinnedTime: isPinned ? hToTime(startH) : null, isAssist, assistPinned: isAssist ? isPinned : false, assistDur: isAssist ? dur : null, assistNote: isAssist ? job._assistNote : null, assistTechId: isAssist ? tech.id : null, style: { left: jLeft + 'px', width: jWidth + 'px', top: '6px', bottom: '6px', position: 'absolute' }, }) prevEndH = startH + dur }) floatingEntries.forEach(entry => { const { job, dur } = entry const startH = job.startHour ?? 8 result.push({ type: 'assist', job: job._parentJob, pinned: false, pinnedTime: null, isAssist: true, assistPinned: false, assistDur: dur, assistNote: job._assistNote, assistTechId: tech.id, style: { left: (startH - H_START) * pxPerHr.value + 'px', width: Math.max(18, dur * pxPerHr.value) + 'px', top: '52%', bottom: '4px', position: 'absolute' }, }) }) return result } // ── Week view helpers ────────────────────────────────────────────────────── function techBookingsByDay (tech) { return dayColumns.value.map(d => { const ds = localDateStr(d) const primary = tech.queue.filter(j => jobSpansDate(j, ds)) const assists = (tech.assistJobs || []) .filter(j => jobSpansDate(j, ds) && j.assistants.find(a => a.techId === tech.id)?.pinned) .map(j => ({ ...j, _isAssistChip: true, _assistDur: j.assistants.find(a => a.techId === tech.id)?.duration || j.duration })) return { day: d, dateStr: ds, jobs: [...primary, ...assists] } }) } function periodLoadH (tech) { const dateSet = new Set(dayColumns.value.map(d => localDateStr(d))) let total = tech.queue.reduce((sum, j) => { const ds = getJobDate(j.id) return ds && dateSet.has(ds) ? sum + (parseFloat(j.duration) || 0) : sum }, 0) ;(tech.assistJobs || []).forEach(j => { const ds = getJobDate(j.id) if (ds && dateSet.has(ds)) { const a = j.assistants.find(x => x.techId === tech.id) if (a?.pinned) total += parseFloat(a?.duration || j.duration) || 0 } }) return total } function techsActiveOnDay (dateStr, resources) { return resources.filter(tech => tech.queue.some(j => jobSpansDate(j, dateStr)) || (tech.assistJobs || []).some(j => jobSpansDate(j, dateStr) && j.assistants.find(a => a.techId === tech.id)?.pinned) ) } function dayJobCount (dateStr, resources) { const jobIds = new Set() resources.forEach(t => t.queue.filter(j => jobSpansDate(j, dateStr)).forEach(j => jobIds.add(j.id))) return jobIds.size } return { H_START, H_END, routeLegs, routeGeometry, techAllJobsForDate, techDayJobsWithTravel, techBookingsByDay, periodLoadH, techsActiveOnDay, dayJobCount, } }