/* app-utils.js — shared helpers for the Bergamo FSR demo */ (function () { 'use strict'; var FSR = window.FSR = window.FSR || {}; FSR.utils = { assignedVisitSet: assignedVisitSet, buildCurl: buildCurl, clonePlan: clonePlan, fetchDemoCatalog: fetchDemoCatalog, fetchDemoPlan: fetchDemoPlan, fetchJobRoutes: fetchJobRoutes, findHeaderButton: findHeaderButton, formatDuration: formatDuration, iconForVisit: iconForVisit, legFor: legFor, locationLat: locationLat, locationLng: locationLng, maskContains: maskContains, routeBadges: routeBadges, routeSchedule: routeSchedule, routeStats: routeStats, timeLabel: timeLabel, title: title, toneForRoute: toneForRoute, }; function buildCurl(method, path, json) { var parts = ['curl']; if (method && method !== 'GET') parts.push('-X', method); if (json) parts.push('-H', '"Content-Type: application/json"', '-d', '@plan.json'); parts.push(window.location.origin + path); return parts.join(' '); } function fetchDemoCatalog() { return requestJson('/demo-data', 'demo data catalog').then(function (catalog) { if (!catalog || typeof catalog.defaultId !== 'string' || !Array.isArray(catalog.availableIds)) { throw new Error('demo data catalog is missing defaultId or availableIds'); } return { defaultId: catalog.defaultId, availableIds: catalog.availableIds.slice() }; }); } function fetchDemoPlan(demoId) { return requestJson('/demo-data/' + encodeURIComponent(demoId), 'demo data "' + demoId + '"'); } function fetchJobRoutes(jobId, snapshotRevision) { return requestJson( '/jobs/' + encodeURIComponent(jobId) + '/routes?snapshot_revision=' + encodeURIComponent(snapshotRevision), 'route geometry for job "' + jobId + '"' ); } function requestJson(path, label) { return fetch(path).then(function (response) { if (!response.ok) throw new Error(label + ' returned HTTP ' + response.status); return response.json(); }); } function routeSchedule(plan, route) { var visits = plan.service_visits || []; var entries = []; var clock = route.shift_start_minute; var previous = route.start_location_idx; (route.visits || []).forEach(function (visitIdx) { var visit = visits[visitIdx]; if (!visit) return; var leg = legFor(plan, previous, visit.location_idx); clock += leg && leg.reachable ? Math.ceil((leg.duration_seconds || 0) / 60) : 0; if (clock < visit.earliest_minute) clock = visit.earliest_minute; var start = clock; var end = start + Math.max(0, visit.duration_minutes || 0); entries.push({ visit: visit, start: start, end: end }); clock = end; previous = visit.location_idx; }); return entries; } function routeStats(plan, route) { var visits = plan.service_visits || []; var travelMinutes = 0; var serviceMinutes = 0; var lateMinutes = 0; var overtimeMinutes = 0; var missingSkills = 0; var missingParts = 0; var unreachable = 0; var clock = route.shift_start_minute; var previous = route.start_location_idx; (route.visits || []).forEach(function (visitIdx) { var visit = visits[visitIdx]; if (!visit) { unreachable += 1; return; } var leg = legFor(plan, previous, visit.location_idx); if (!leg || !leg.reachable) { unreachable += 1; } else { var legMinutes = Math.ceil((leg.duration_seconds || 0) / 60); travelMinutes += legMinutes; clock += legMinutes; } if (clock < visit.earliest_minute) clock = visit.earliest_minute; if (clock > visit.latest_minute) lateMinutes += clock - visit.latest_minute; if (!maskContains(route.skill_mask, visit.required_skill_mask)) missingSkills += 1; if (!maskContains(route.inventory_mask, visit.required_parts_mask)) missingParts += 1; serviceMinutes += Math.max(0, visit.duration_minutes || 0); clock += Math.max(0, visit.duration_minutes || 0); previous = visit.location_idx; }); var returnLeg = legFor(plan, previous, route.end_location_idx); if (!returnLeg || !returnLeg.reachable) { unreachable += 1; } else { var returnMinutes = Math.ceil((returnLeg.duration_seconds || 0) / 60); travelMinutes += returnMinutes; clock += returnMinutes; } var routeMinutes = Math.max(0, clock - route.shift_start_minute); overtimeMinutes += Math.max(0, clock - route.shift_end_minute); overtimeMinutes += Math.max(0, routeMinutes - route.max_route_minutes); return { travelMinutes: travelMinutes, serviceMinutes: serviceMinutes, lateMinutes: lateMinutes, overtimeMinutes: overtimeMinutes, missingSkills: missingSkills, missingParts: missingParts, unreachable: unreachable, }; } function legFor(plan, from, to) { var width = (plan.locations || []).length; var direct = (plan.travel_legs || [])[from * width + to]; if (direct && direct.from_location_idx === from && direct.to_location_idx === to) { return direct; } return (plan.travel_legs || []).find(function (leg) { return leg.from_location_idx === from && leg.to_location_idx === to; }); } function assignedVisitSet(routes) { var assigned = {}; (routes || []).forEach(function (route) { (route.visits || []).forEach(function (visitIdx) { assigned[visitIdx] = route; }); }); return assigned; } function locationLat(location) { if (location.lat != null) return Number(location.lat); return Number(location.lat_e6 || 0) / 1000000; } function locationLng(location) { if (location.lng != null) return Number(location.lng); return Number(location.lng_e6 || 0) / 1000000; } function routeBadges(stats) { var badges = []; if (stats.unreachable) badges.push('Routing'); if (stats.missingSkills) badges.push('Skills'); if (stats.missingParts) badges.push('Parts'); if (stats.lateMinutes) badges.push('Late'); if (stats.overtimeMinutes) badges.push('Overtime'); return badges.length ? badges : ['Feasible']; } function iconForVisit(visit) { if ((visit.required_skill_mask & 8) === 8) return 'fa-elevator'; if ((visit.required_skill_mask & 4) === 4) return 'fa-faucet'; if ((visit.required_skill_mask & 2) === 2) return 'fa-bolt'; return 'fa-screwdriver-wrench'; } function maskContains(available, required) { return ((available || 0) & (required || 0)) === (required || 0); } function toneForRoute(index) { return ['blue', 'emerald', 'amber', 'rose', 'violet', 'slate'][index % 6]; } function formatDuration(minutes) { var value = Math.max(0, Math.round(minutes || 0)); var h = Math.floor(value / 60); var m = value % 60; if (!h) return String(m) + 'm'; return String(h) + 'h ' + String(m).padStart(2, '0') + 'm'; } function timeLabel(minute) { var h = Math.floor(minute / 60); var m = minute % 60; return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'); } function findHeaderButton(header, label) { var buttons = header.querySelectorAll('button'); for (var i = 0; i < buttons.length; i += 1) { if ((buttons[i].textContent || '').trim() === label) return buttons[i]; } return null; } function clonePlan(data) { return JSON.parse(JSON.stringify(data)); } function title(text) { return String(text || '') .replace(/_/g, ' ') .replace(/\b\w/g, function (match) { return match.toUpperCase(); }); } })();