gigafibre-fsm/apps/dispatch/src/composables/useScheduler.js
louispaulb 7da22ff132 merge: import dispatch-app into apps/dispatch/ (17 commits preserved)
Integrates the Dispatch PWA (Vue/Quasar) into the gigafibre-fsm monorepo.
Full git history accessible via `git log -- apps/dispatch/`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:08:51 -04:00

210 lines
9.1 KiB
JavaScript

// ── 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,
}
}