blackopsrepl commited on
Commit
66c0efc
·
1 Parent(s): 531908f

refactor(ui): split field service workspace scripts

Browse files

Move the field-service workspace helpers and rendering logic out of the bootstrap script so the frontend stays under the 300-line file-size limit.

The index now loads dedicated utility and renderer modules before the application bootstrap. The rendered summary also accounts for unassigned visits when reporting hard issues, which keeps the UI truthful while demo plans start empty.

Files changed (4) hide show
  1. static/app-render.js +281 -0
  2. static/app-utils.js +225 -0
  3. static/app.js +52 -576
  4. static/index.html +2 -0
static/app-render.js ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* app-render.js — rendering layer for the Bergamo FSR demo */
2
+
3
+ (function () {
4
+ 'use strict';
5
+
6
+ var FSR = window.FSR = window.FSR || {};
7
+ var utils = FSR.utils;
8
+
9
+ FSR.createRenderer = function (options) {
10
+ var SF = options.SF;
11
+ var routeTimeline = null;
12
+
13
+ return {
14
+ buildAnalysisHtml: buildAnalysisHtml,
15
+ destroy: destroy,
16
+ renderAll: renderAll,
17
+ renderApiGuide: renderApiGuide,
18
+ };
19
+
20
+ function renderAll(plan) {
21
+ renderSummary(plan);
22
+ renderMap(plan);
23
+ renderRouteCards(plan);
24
+ renderTimeline(plan);
25
+ renderTables(plan);
26
+ }
27
+
28
+ function renderSummary(plan) {
29
+ var routes = plan.technician_routes || [];
30
+ var visits = plan.service_visits || [];
31
+ var assigned = utils.assignedVisitSet(routes);
32
+ var assignedCount = Object.keys(assigned).length;
33
+ var routeMetrics = routes.map(function (route) { return utils.routeStats(plan, route); });
34
+ var travelMinutes = routeMetrics.reduce(function (sum, stats) { return sum + stats.travelMinutes; }, 0);
35
+ var serviceMinutes = routeMetrics.reduce(function (sum, stats) { return sum + stats.serviceMinutes; }, 0);
36
+ var routeIssues = routeMetrics.reduce(function (sum, stats) {
37
+ return sum + stats.unreachable + stats.missingSkills + stats.missingParts + stats.lateMinutes + stats.overtimeMinutes;
38
+ }, 0);
39
+
40
+ options.summaryContainer.innerHTML = '';
41
+ options.summaryContainer.appendChild(SF.createTable({
42
+ columns: ['Dataset', 'Visits', 'Assigned', 'Technicians', 'Travel', 'Service', 'Hard issues', 'Score'],
43
+ rows: [[
44
+ options.getDemoCatalog().defaultId || 'STANDARD',
45
+ String(visits.length),
46
+ String(assignedCount),
47
+ String(routes.length),
48
+ utils.formatDuration(travelMinutes),
49
+ utils.formatDuration(serviceMinutes),
50
+ String(routeIssues + Math.max(0, visits.length - assignedCount)),
51
+ String(plan.score || 'unsolved'),
52
+ ]],
53
+ }));
54
+ }
55
+
56
+ function renderMap(plan) {
57
+ if (!options.routeMap) return;
58
+ options.routeMap.clearAll();
59
+
60
+ var locations = plan.locations || [];
61
+ var visits = plan.service_visits || [];
62
+ var routes = plan.technician_routes || [];
63
+ var assigned = utils.assignedVisitSet(routes);
64
+
65
+ routes.forEach(function (route) {
66
+ var start = locations[route.start_location_idx];
67
+ if (!start) return;
68
+ options.routeMap.addVehicleMarker({
69
+ lat: utils.locationLat(start),
70
+ lng: utils.locationLng(start),
71
+ color: route.color || '#2563eb',
72
+ });
73
+ });
74
+
75
+ visits.forEach(function (visit, idx) {
76
+ var location = locations[visit.location_idx];
77
+ if (!location) return;
78
+ options.routeMap.addVisitMarker({
79
+ lat: utils.locationLat(location),
80
+ lng: utils.locationLng(location),
81
+ color: assigned[idx] ? assigned[idx].color : '#64748b',
82
+ icon: utils.iconForVisit(visit),
83
+ assigned: !!assigned[idx],
84
+ });
85
+ });
86
+
87
+ routes.forEach(function (route) {
88
+ var previous = route.start_location_idx;
89
+ (route.visits || []).forEach(function (visitIdx, sequenceIdx) {
90
+ var visit = visits[visitIdx];
91
+ if (!visit) return;
92
+ drawLeg(plan, previous, visit.location_idx, route.color);
93
+ addStopNumber(locations[visit.location_idx], sequenceIdx + 1, route.color);
94
+ previous = visit.location_idx;
95
+ });
96
+ drawLeg(plan, previous, route.end_location_idx, route.color);
97
+ });
98
+
99
+ options.routeMap.fitBounds();
100
+ }
101
+
102
+ function addStopNumber(location, number, color) {
103
+ if (!location) return;
104
+ options.routeMap.addStopNumber({
105
+ lat: utils.locationLat(location),
106
+ lng: utils.locationLng(location),
107
+ number: number,
108
+ color: color || '#2563eb',
109
+ });
110
+ }
111
+
112
+ function renderRouteCards(plan) {
113
+ options.routeCards.innerHTML = '';
114
+ renderUnassignedCard(plan);
115
+ (plan.technician_routes || []).forEach(function (route) {
116
+ var stats = utils.routeStats(plan, route);
117
+ var card = SF.el('div', {
118
+ className: 'sf-section',
119
+ style: { borderLeft: '4px solid ' + (route.color || '#2563eb'), padding: '12px', borderRadius: '8px' },
120
+ });
121
+ card.appendChild(SF.el('h3', { style: { margin: '0 0 8px' } }, route.technician_name || route.id));
122
+ card.appendChild(SF.createTable({
123
+ columns: ['Stops', 'Travel', 'Service'],
124
+ rows: [[String((route.visits || []).length), utils.formatDuration(stats.travelMinutes), utils.formatDuration(stats.serviceMinutes)]],
125
+ }));
126
+ card.appendChild(SF.createTable({
127
+ columns: ['Territory', 'Late', 'Overtime'],
128
+ rows: [[route.territory || '-', utils.formatDuration(stats.lateMinutes), utils.formatDuration(stats.overtimeMinutes)]],
129
+ }));
130
+ options.routeCards.appendChild(card);
131
+ });
132
+ }
133
+
134
+ function renderUnassignedCard(plan) {
135
+ var assigned = utils.assignedVisitSet(plan.technician_routes || []);
136
+ var rows = (plan.service_visits || []).reduce(function (items, visit, idx) {
137
+ if (assigned[idx]) return items;
138
+ items.push([
139
+ visit.customer || visit.name || visit.id,
140
+ utils.timeLabel(visit.earliest_minute) + '-' + utils.timeLabel(visit.latest_minute),
141
+ utils.formatDuration(visit.duration_minutes || 0),
142
+ ]);
143
+ return items;
144
+ }, []);
145
+ if (!rows.length) return;
146
+
147
+ var card = SF.el('div', {
148
+ className: 'sf-section',
149
+ style: { borderLeft: '4px solid #64748b', padding: '12px', borderRadius: '8px' },
150
+ });
151
+ card.appendChild(SF.el('h3', { style: { margin: '0 0 8px' } }, 'Unassigned visits'));
152
+ card.appendChild(SF.createTable({ columns: ['Visit', 'Window', 'Duration'], rows: rows }));
153
+ options.routeCards.appendChild(card);
154
+ }
155
+
156
+ function renderTimeline(plan) {
157
+ var timelineConfig = buildTimelineConfig(plan);
158
+ options.timelineContainer.innerHTML = '';
159
+ options.timelineContainer.appendChild(SF.el('div', { className: 'sf-section' }, SF.createTable({
160
+ columns: ['Route lanes', 'Window', 'Source'],
161
+ rows: [[String((plan.technician_routes || []).length), '08:00-18:00', 'Latest SolverForge solution payload']],
162
+ })));
163
+
164
+ if (!routeTimeline) routeTimeline = SF.rail.createTimeline(timelineConfig);
165
+ else routeTimeline.setModel(timelineConfig.model);
166
+ options.timelineContainer.appendChild(routeTimeline.el);
167
+ }
168
+
169
+ function buildTimelineConfig(plan) {
170
+ return {
171
+ label: 'Technician',
172
+ labelWidth: 280,
173
+ title: 'Bergamo Field Service Routes',
174
+ subtitle: 'Ordered service visits per technician',
175
+ model: {
176
+ axis: buildDayAxis(),
177
+ lanes: (plan.technician_routes || []).map(routeLane(plan)),
178
+ },
179
+ };
180
+ }
181
+
182
+ function routeLane(plan) {
183
+ return function (route, routeIdx) {
184
+ var items = utils.routeSchedule(plan, route).map(function (entry, entryIdx) {
185
+ return {
186
+ id: 'route-' + routeIdx + '-visit-' + entryIdx,
187
+ startMinute: entry.start,
188
+ endMinute: entry.end,
189
+ label: entry.visit.customer || entry.visit.name || entry.visit.id,
190
+ meta: utils.timeLabel(entry.start) + '-' + utils.timeLabel(entry.end),
191
+ tone: utils.toneForRoute(routeIdx),
192
+ };
193
+ });
194
+ var stats = utils.routeStats(plan, route);
195
+ return {
196
+ id: route.id || ('route-' + routeIdx),
197
+ label: route.technician_name || route.id || ('Technician ' + (routeIdx + 1)),
198
+ mode: 'detailed',
199
+ badges: utils.routeBadges(stats),
200
+ stats: [
201
+ { label: 'Stops', value: (route.visits || []).length },
202
+ { label: 'Travel', value: utils.formatDuration(stats.travelMinutes) },
203
+ { label: 'Service', value: utils.formatDuration(stats.serviceMinutes) },
204
+ ],
205
+ items: items,
206
+ };
207
+ };
208
+ }
209
+
210
+ function buildDayAxis() {
211
+ var ticks = [];
212
+ for (var minute = options.dayStart; minute <= options.dayEnd; minute += 60) {
213
+ ticks.push({ id: 'tick-' + minute, minute: minute, label: utils.timeLabel(minute) });
214
+ }
215
+ return {
216
+ startMinute: options.dayStart,
217
+ endMinute: options.dayEnd,
218
+ days: [{ id: 'bergamo-day', label: 'Service day', subLabel: '08:00-18:00', startMinute: options.dayStart, endMinute: options.dayEnd }],
219
+ ticks: ticks,
220
+ initialViewport: { startMinute: options.dayStart, endMinute: options.dayEnd },
221
+ };
222
+ }
223
+
224
+ function renderTables(plan) {
225
+ options.tablesContainer.innerHTML = '';
226
+ ['technician_routes', 'service_visits', 'locations'].forEach(function (key) {
227
+ var rows = plan[key] || [];
228
+ if (!rows.length) return;
229
+ var columns = Object.keys(rows[0]).filter(function (column) { return column !== 'score' && column !== 'encoded_polyline'; });
230
+ var values = rows.map(function (row) {
231
+ return columns.map(function (column) {
232
+ var value = row[column];
233
+ if (value == null) return '-';
234
+ if (Array.isArray(value)) return value.join(', ');
235
+ return String(value);
236
+ });
237
+ });
238
+ var section = SF.el('div', { className: 'sf-section' });
239
+ section.appendChild(SF.el('h3', null, utils.title(key)));
240
+ section.appendChild(SF.createTable({ columns: columns, rows: values }));
241
+ options.tablesContainer.appendChild(section);
242
+ });
243
+ }
244
+
245
+ function renderApiGuide() {
246
+ var catalog = options.getDemoCatalog();
247
+ options.apiGuideContainer.innerHTML = '';
248
+ options.apiGuideContainer.appendChild(SF.createApiGuide({
249
+ endpoints: [
250
+ { method: 'GET', path: '/demo-data', description: 'Discover demo datasets', curl: utils.buildCurl('GET', '/demo-data') },
251
+ { method: 'GET', path: '/demo-data/' + (catalog.defaultId || '{defaultId}'), description: 'Fetch Bergamo OSM-backed demo data', curl: utils.buildCurl('GET', '/demo-data/' + (catalog.defaultId || 'STANDARD')) },
252
+ { method: 'POST', path: '/jobs', description: 'Create a retained solve job', curl: utils.buildCurl('POST', '/jobs', true) },
253
+ { method: 'GET', path: '/jobs/{id}/events', description: 'Stream typed SolverForge lifecycle events', curl: utils.buildCurl('GET', '/jobs/{id}/events') },
254
+ { method: 'GET', path: '/jobs/{id}/snapshot', description: 'Fetch latest route snapshot', curl: utils.buildCurl('GET', '/jobs/{id}/snapshot') },
255
+ { method: 'GET', path: '/jobs/{id}/analysis', description: 'Analyze the latest retained score', curl: utils.buildCurl('GET', '/jobs/{id}/analysis') },
256
+ ],
257
+ }));
258
+ }
259
+
260
+ function drawLeg(plan, from, to, color) {
261
+ var leg = utils.legFor(plan, from, to);
262
+ if (!leg || !leg.reachable || !leg.encoded_polyline) return;
263
+ options.routeMap.drawEncodedRoute({ encoded: leg.encoded_polyline, color: color || '#2563eb' });
264
+ }
265
+
266
+ function buildAnalysisHtml(analysis) {
267
+ if (!analysis || !analysis.constraints) return '<p>No analysis available.</p>';
268
+ var html = '<p><strong>Score:</strong> ' + SF.escHtml(analysis.score) + '</p>';
269
+ html += '<table class="sf-table"><thead><tr><th>Constraint</th><th>Weight</th><th>Score</th><th>Matches</th></tr></thead><tbody>';
270
+ analysis.constraints.forEach(function (constraint) {
271
+ html += '<tr><td>' + SF.escHtml(constraint.name) + '</td><td>' + SF.escHtml(constraint.weight) + '</td><td>' + SF.escHtml(constraint.score) + '</td><td>' + String(constraint.matchCount || 0) + '</td></tr>';
272
+ });
273
+ html += '</tbody></table>';
274
+ return html;
275
+ }
276
+
277
+ function destroy() {
278
+ if (routeTimeline) routeTimeline.destroy();
279
+ }
280
+ };
281
+ })();
static/app-utils.js ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* app-utils.js — shared helpers for the Bergamo FSR demo */
2
+
3
+ (function () {
4
+ 'use strict';
5
+
6
+ var FSR = window.FSR = window.FSR || {};
7
+
8
+ FSR.utils = {
9
+ assignedVisitSet: assignedVisitSet,
10
+ buildCurl: buildCurl,
11
+ clonePlan: clonePlan,
12
+ fetchDemoCatalog: fetchDemoCatalog,
13
+ fetchDemoPlan: fetchDemoPlan,
14
+ findHeaderButton: findHeaderButton,
15
+ formatDuration: formatDuration,
16
+ iconForVisit: iconForVisit,
17
+ legFor: legFor,
18
+ locationLat: locationLat,
19
+ locationLng: locationLng,
20
+ maskContains: maskContains,
21
+ routeBadges: routeBadges,
22
+ routeSchedule: routeSchedule,
23
+ routeStats: routeStats,
24
+ timeLabel: timeLabel,
25
+ title: title,
26
+ toneForRoute: toneForRoute,
27
+ };
28
+
29
+ function buildCurl(method, path, json) {
30
+ var parts = ['curl'];
31
+ if (method && method !== 'GET') parts.push('-X', method);
32
+ if (json) parts.push('-H', '"Content-Type: application/json"', '-d', '@plan.json');
33
+ parts.push(window.location.origin + path);
34
+ return parts.join(' ');
35
+ }
36
+
37
+ function fetchDemoCatalog() {
38
+ return requestJson('/demo-data', 'demo data catalog').then(function (catalog) {
39
+ if (!catalog || typeof catalog.defaultId !== 'string' || !Array.isArray(catalog.availableIds)) {
40
+ throw new Error('demo data catalog is missing defaultId or availableIds');
41
+ }
42
+ return { defaultId: catalog.defaultId, availableIds: catalog.availableIds.slice() };
43
+ });
44
+ }
45
+
46
+ function fetchDemoPlan(demoId) {
47
+ return requestJson('/demo-data/' + encodeURIComponent(demoId), 'demo data "' + demoId + '"');
48
+ }
49
+
50
+ function requestJson(path, label) {
51
+ return fetch(path).then(function (response) {
52
+ if (!response.ok) throw new Error(label + ' returned HTTP ' + response.status);
53
+ return response.json();
54
+ });
55
+ }
56
+
57
+ function routeSchedule(plan, route) {
58
+ var visits = plan.service_visits || [];
59
+ var entries = [];
60
+ var clock = route.shift_start_minute;
61
+ var previous = route.start_location_idx;
62
+
63
+ (route.visits || []).forEach(function (visitIdx) {
64
+ var visit = visits[visitIdx];
65
+ if (!visit) return;
66
+ var leg = legFor(plan, previous, visit.location_idx);
67
+ clock += leg && leg.reachable ? Math.ceil((leg.duration_seconds || 0) / 60) : 0;
68
+ if (clock < visit.earliest_minute) clock = visit.earliest_minute;
69
+ var start = clock;
70
+ var end = start + Math.max(0, visit.duration_minutes || 0);
71
+ entries.push({ visit: visit, start: start, end: end });
72
+ clock = end;
73
+ previous = visit.location_idx;
74
+ });
75
+
76
+ return entries;
77
+ }
78
+
79
+ function routeStats(plan, route) {
80
+ var visits = plan.service_visits || [];
81
+ var travelMinutes = 0;
82
+ var serviceMinutes = 0;
83
+ var lateMinutes = 0;
84
+ var overtimeMinutes = 0;
85
+ var missingSkills = 0;
86
+ var missingParts = 0;
87
+ var unreachable = 0;
88
+ var clock = route.shift_start_minute;
89
+ var previous = route.start_location_idx;
90
+
91
+ (route.visits || []).forEach(function (visitIdx) {
92
+ var visit = visits[visitIdx];
93
+ if (!visit) {
94
+ unreachable += 1;
95
+ return;
96
+ }
97
+ var leg = legFor(plan, previous, visit.location_idx);
98
+ if (!leg || !leg.reachable) {
99
+ unreachable += 1;
100
+ } else {
101
+ var legMinutes = Math.ceil((leg.duration_seconds || 0) / 60);
102
+ travelMinutes += legMinutes;
103
+ clock += legMinutes;
104
+ }
105
+ if (clock < visit.earliest_minute) clock = visit.earliest_minute;
106
+ if (clock > visit.latest_minute) lateMinutes += clock - visit.latest_minute;
107
+ if (!maskContains(route.skill_mask, visit.required_skill_mask)) missingSkills += 1;
108
+ if (!maskContains(route.inventory_mask, visit.required_parts_mask)) missingParts += 1;
109
+ serviceMinutes += Math.max(0, visit.duration_minutes || 0);
110
+ clock += Math.max(0, visit.duration_minutes || 0);
111
+ previous = visit.location_idx;
112
+ });
113
+
114
+ var returnLeg = legFor(plan, previous, route.end_location_idx);
115
+ if (!returnLeg || !returnLeg.reachable) {
116
+ unreachable += 1;
117
+ } else {
118
+ var returnMinutes = Math.ceil((returnLeg.duration_seconds || 0) / 60);
119
+ travelMinutes += returnMinutes;
120
+ clock += returnMinutes;
121
+ }
122
+
123
+ var routeMinutes = Math.max(0, clock - route.shift_start_minute);
124
+ overtimeMinutes += Math.max(0, clock - route.shift_end_minute);
125
+ overtimeMinutes += Math.max(0, routeMinutes - route.max_route_minutes);
126
+
127
+ return {
128
+ travelMinutes: travelMinutes,
129
+ serviceMinutes: serviceMinutes,
130
+ lateMinutes: lateMinutes,
131
+ overtimeMinutes: overtimeMinutes,
132
+ missingSkills: missingSkills,
133
+ missingParts: missingParts,
134
+ unreachable: unreachable,
135
+ };
136
+ }
137
+
138
+ function legFor(plan, from, to) {
139
+ var width = (plan.locations || []).length;
140
+ var direct = (plan.travel_legs || [])[from * width + to];
141
+ if (direct && direct.from_location_idx === from && direct.to_location_idx === to) {
142
+ return direct;
143
+ }
144
+ return (plan.travel_legs || []).find(function (leg) {
145
+ return leg.from_location_idx === from && leg.to_location_idx === to;
146
+ });
147
+ }
148
+
149
+ function assignedVisitSet(routes) {
150
+ var assigned = {};
151
+ (routes || []).forEach(function (route) {
152
+ (route.visits || []).forEach(function (visitIdx) {
153
+ assigned[visitIdx] = route;
154
+ });
155
+ });
156
+ return assigned;
157
+ }
158
+
159
+ function locationLat(location) {
160
+ if (location.lat != null) return Number(location.lat);
161
+ return Number(location.lat_e6 || 0) / 1000000;
162
+ }
163
+
164
+ function locationLng(location) {
165
+ if (location.lng != null) return Number(location.lng);
166
+ return Number(location.lng_e6 || 0) / 1000000;
167
+ }
168
+
169
+ function routeBadges(stats) {
170
+ var badges = [];
171
+ if (stats.unreachable) badges.push('Routing');
172
+ if (stats.missingSkills) badges.push('Skills');
173
+ if (stats.missingParts) badges.push('Parts');
174
+ if (stats.lateMinutes) badges.push('Late');
175
+ if (stats.overtimeMinutes) badges.push('Overtime');
176
+ return badges.length ? badges : ['Feasible'];
177
+ }
178
+
179
+ function iconForVisit(visit) {
180
+ if ((visit.required_skill_mask & 8) === 8) return 'fa-elevator';
181
+ if ((visit.required_skill_mask & 4) === 4) return 'fa-faucet';
182
+ if ((visit.required_skill_mask & 2) === 2) return 'fa-bolt';
183
+ return 'fa-screwdriver-wrench';
184
+ }
185
+
186
+ function maskContains(available, required) {
187
+ return ((available || 0) & (required || 0)) === (required || 0);
188
+ }
189
+
190
+ function toneForRoute(index) {
191
+ return ['blue', 'emerald', 'amber', 'rose', 'violet', 'slate'][index % 6];
192
+ }
193
+
194
+ function formatDuration(minutes) {
195
+ var value = Math.max(0, Math.round(minutes || 0));
196
+ var h = Math.floor(value / 60);
197
+ var m = value % 60;
198
+ if (!h) return String(m) + 'm';
199
+ return String(h) + 'h ' + String(m).padStart(2, '0') + 'm';
200
+ }
201
+
202
+ function timeLabel(minute) {
203
+ var h = Math.floor(minute / 60);
204
+ var m = minute % 60;
205
+ return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
206
+ }
207
+
208
+ function findHeaderButton(header, label) {
209
+ var buttons = header.querySelectorAll('button');
210
+ for (var i = 0; i < buttons.length; i += 1) {
211
+ if ((buttons[i].textContent || '').trim() === label) return buttons[i];
212
+ }
213
+ return null;
214
+ }
215
+
216
+ function clonePlan(data) {
217
+ return JSON.parse(JSON.stringify(data));
218
+ }
219
+
220
+ function title(text) {
221
+ return String(text || '')
222
+ .replace(/_/g, ' ')
223
+ .replace(/\b\w/g, function (match) { return match.toUpperCase(); });
224
+ }
225
+ })();
static/app.js CHANGED
@@ -1,4 +1,4 @@
1
- /* app.js — Bergamo field service routing demo using SolverForge UI/maps */
2
 
3
  (async function () {
4
  'use strict';
@@ -6,6 +6,7 @@
6
  var DAY_START = 8 * 60;
7
  var DAY_END = 18 * 60;
8
  var DEFAULT_CENTER = [45.698, 9.677];
 
9
 
10
  var config = await fetch('/sf-config.json').then(function (response) { return response.json(); });
11
  var uiModel = await fetch('/generated/ui-model.json').then(function (response) { return response.json(); });
@@ -18,7 +19,7 @@
18
  var bootstrapError = null;
19
  var lastAnalysis = null;
20
  var routeMap = null;
21
- var routeTimeline = null;
22
 
23
  var panels = {
24
  map: SF.el('div', { className: 'sf-content' }),
@@ -88,13 +89,7 @@
88
  style: { minHeight: '560px', borderRadius: '8px' },
89
  });
90
  var routeCards = SF.el('div', {
91
- style: {
92
- display: 'grid',
93
- gap: '10px',
94
- alignContent: 'start',
95
- maxHeight: '560px',
96
- overflow: 'auto',
97
- },
98
  });
99
  mapShell.appendChild(mapContainer);
100
  mapShell.appendChild(routeCards);
@@ -102,12 +97,10 @@
102
  panels.map.appendChild(mapShell);
103
 
104
  var timelineContainer = SF.el('div');
105
- panels.routes.appendChild(timelineContainer);
106
-
107
  var tablesContainer = SF.el('div');
108
- panels.data.appendChild(tablesContainer);
109
-
110
  var apiGuideContainer = SF.el('div');
 
 
111
  panels.api.appendChild(apiGuideContainer);
112
 
113
  app.appendChild(panels.map);
@@ -122,51 +115,45 @@
122
  }));
123
 
124
  routeMap = SF.map.create({ container: 'fsr-map', center: DEFAULT_CENTER, zoom: 13 });
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
  var analysisModal = SF.createModal({ title: 'Score Analysis', width: '760px' });
127
  var solver = SF.createSolver({
128
  backend: backend,
129
  statusBar: statusBar,
130
- onProgress: function (meta) {
131
- syncLifecycleMarkers(meta);
132
- },
133
- onPauseRequested: function (meta) {
134
- syncLifecycleMarkers(meta);
135
- },
136
  onSolution: function (snapshot, meta) {
137
- if (snapshot && snapshot.solution) {
138
- renderAll(snapshot.solution);
139
- }
140
  syncLifecycleMarkers(meta);
141
  },
142
  onPaused: function (snapshot, meta) {
143
- if (snapshot && snapshot.solution) {
144
- renderAll(snapshot.solution);
145
- }
146
- syncLifecycleMarkers(meta);
147
- },
148
- onResumed: function (meta) {
149
  syncLifecycleMarkers(meta);
150
  },
 
151
  onCancelled: function (snapshot, meta) {
152
- if (snapshot && snapshot.solution) {
153
- renderAll(snapshot.solution);
154
- }
155
  syncLifecycleMarkers(meta);
156
  },
157
  onComplete: function (snapshot, meta) {
158
- if (snapshot && snapshot.solution) {
159
- renderAll(snapshot.solution);
160
- }
161
  syncLifecycleMarkers(meta);
162
  },
163
  onFailure: function (message, meta, snapshot, analysis) {
164
- if (snapshot && snapshot.solution) {
165
- renderAll(snapshot.solution);
166
- }
167
- if (analysis) {
168
- lastAnalysis = analysis;
169
- }
170
  console.error('Solver job failed:', message);
171
  syncLifecycleMarkers(meta);
172
  },
@@ -180,15 +167,10 @@
180
  },
181
  });
182
 
183
- renderApiGuide();
184
  updateSolveActionAvailability();
185
  bootstrapDemoData();
186
-
187
- window.addEventListener('beforeunload', function () {
188
- if (routeTimeline) {
189
- routeTimeline.destroy();
190
- }
191
- });
192
 
193
  function loadAndSolve() {
194
  if (solver.isRunning() || solver.getLifecycleState() === 'PAUSED' || !canSolve()) return;
@@ -200,21 +182,15 @@
200
  }
201
 
202
  function pauseSolve() {
203
- solver.pause()
204
- .then(function () { syncLifecycleMarkers(); })
205
- .catch(function (err) { console.error('Pause failed:', err); });
206
  }
207
 
208
  function resumeSolve() {
209
- solver.resume()
210
- .then(function () { syncLifecycleMarkers(); })
211
- .catch(function (err) { console.error('Resume failed:', err); });
212
  }
213
 
214
  function cancelSolve() {
215
- solver.cancel()
216
- .then(function () { syncLifecycleMarkers(); })
217
- .catch(function (err) { console.error('Cancel failed:', err); });
218
  }
219
 
220
  function openAnalysis() {
@@ -222,7 +198,7 @@
222
  solver.analyzeSnapshot()
223
  .then(function (analysis) {
224
  lastAnalysis = analysis;
225
- analysisModal.setBody(buildAnalysisHtml(analysis));
226
  analysisModal.open();
227
  })
228
  .catch(function (err) { console.error('Analysis failed:', err); });
@@ -233,511 +209,40 @@
233
  if (!solver.getJobId() || state === 'IDLE' || state === 'PAUSED' || solver.isRunning()) {
234
  return Promise.resolve(null);
235
  }
236
- return solver.delete()
237
- .then(function () {
238
- lastAnalysis = null;
239
- syncLifecycleMarkers();
240
- });
241
  }
242
 
243
  function resolvePlanForSolve() {
244
- if (currentPlan) {
245
- return Promise.resolve(clonePlan(currentPlan));
246
- }
247
- if (!demoCatalog.defaultId) {
248
- return Promise.reject(new Error('demo data catalog is unavailable'));
249
- }
250
- return fetchDemoPlan(demoCatalog.defaultId);
251
  }
252
 
253
  function bootstrapDemoData() {
254
- fetchDemoCatalog()
255
  .then(function (catalog) {
256
  demoCatalog = catalog;
257
  clearBootstrapError();
258
- renderApiGuide();
259
- return fetchDemoPlan(catalog.defaultId);
260
  })
261
  .then(function (plan) {
262
- renderAll(plan);
263
  updateSolveActionAvailability();
264
  })
265
- .catch(function (err) {
266
- reportBootstrapError(err);
267
- });
268
- }
269
-
270
- function fetchDemoCatalog() {
271
- return requestJson('/demo-data', 'demo data catalog')
272
- .then(function (catalog) {
273
- if (!catalog || typeof catalog.defaultId !== 'string' || !Array.isArray(catalog.availableIds)) {
274
- throw new Error('demo data catalog is missing defaultId or availableIds');
275
- }
276
- return {
277
- defaultId: catalog.defaultId,
278
- availableIds: catalog.availableIds.slice(),
279
- };
280
- });
281
- }
282
-
283
- function fetchDemoPlan(demoId) {
284
- return requestJson('/demo-data/' + encodeURIComponent(demoId), 'demo data "' + demoId + '"');
285
- }
286
-
287
- function requestJson(path, label) {
288
- return fetch(path)
289
- .then(function (response) {
290
- if (!response.ok) {
291
- throw new Error(label + ' returned HTTP ' + response.status);
292
- }
293
- return response.json();
294
- });
295
- }
296
-
297
- function renderAll(plan) {
298
- currentPlan = clonePlan(plan);
299
- renderSummary(plan);
300
- renderMap(plan);
301
- renderRouteCards(plan);
302
- renderTimeline(plan);
303
- renderTables(plan);
304
- }
305
-
306
- function renderSummary(plan) {
307
- var routes = plan.technician_routes || [];
308
- var visits = plan.service_visits || [];
309
- var assigned = assignedVisitSet(routes);
310
- var routeMetrics = routes.map(function (route) { return routeStats(plan, route); });
311
- var travelMinutes = routeMetrics.reduce(function (sum, stats) { return sum + stats.travelMinutes; }, 0);
312
- var serviceMinutes = routeMetrics.reduce(function (sum, stats) { return sum + stats.serviceMinutes; }, 0);
313
- var hardIssues = routeMetrics.reduce(function (sum, stats) {
314
- return sum + stats.unreachable + stats.missingSkills + stats.missingParts + stats.lateMinutes + stats.overtimeMinutes;
315
- }, 0);
316
-
317
- summaryContainer.innerHTML = '';
318
- summaryContainer.appendChild(SF.createTable({
319
- columns: ['Dataset', 'Visits', 'Assigned', 'Technicians', 'Travel', 'Service', 'Hard issues', 'Score'],
320
- rows: [[
321
- demoCatalog.defaultId || 'STANDARD',
322
- String(visits.length),
323
- String(Object.keys(assigned).length),
324
- String(routes.length),
325
- formatDuration(travelMinutes),
326
- formatDuration(serviceMinutes),
327
- String(hardIssues),
328
- String(plan.score || 'unsolved'),
329
- ]],
330
- }));
331
- }
332
-
333
- function renderMap(plan) {
334
- if (!routeMap) return;
335
- routeMap.clearAll();
336
-
337
- var locations = plan.locations || [];
338
- var visits = plan.service_visits || [];
339
- var routes = plan.technician_routes || [];
340
- var assigned = assignedVisitSet(routes);
341
-
342
- routes.forEach(function (route) {
343
- var start = locations[route.start_location_idx];
344
- if (start) {
345
- routeMap.addVehicleMarker({
346
- lat: locationLat(start),
347
- lng: locationLng(start),
348
- color: route.color || '#2563eb',
349
- });
350
- }
351
- });
352
-
353
- visits.forEach(function (visit, idx) {
354
- var location = locations[visit.location_idx];
355
- if (!location) return;
356
- routeMap.addVisitMarker({
357
- lat: locationLat(location),
358
- lng: locationLng(location),
359
- color: assigned[idx] ? assigned[idx].color : '#64748b',
360
- icon: iconForVisit(visit),
361
- assigned: !!assigned[idx],
362
- });
363
- });
364
-
365
- routes.forEach(function (route) {
366
- var previous = route.start_location_idx;
367
- (route.visits || []).forEach(function (visitIdx, sequenceIdx) {
368
- var visit = visits[visitIdx];
369
- if (!visit) return;
370
- drawLeg(plan, previous, visit.location_idx, route.color);
371
- var stopLocation = locations[visit.location_idx];
372
- if (stopLocation) {
373
- routeMap.addStopNumber({
374
- lat: locationLat(stopLocation),
375
- lng: locationLng(stopLocation),
376
- number: sequenceIdx + 1,
377
- color: route.color || '#2563eb',
378
- });
379
- }
380
- previous = visit.location_idx;
381
- });
382
- drawLeg(plan, previous, route.end_location_idx, route.color);
383
- });
384
-
385
- routeMap.fitBounds();
386
- }
387
-
388
- function renderRouteCards(plan) {
389
- routeCards.innerHTML = '';
390
- renderUnassignedCard(plan);
391
- (plan.technician_routes || []).forEach(function (route) {
392
- var stats = routeStats(plan, route);
393
- var card = SF.el('div', {
394
- className: 'sf-section',
395
- style: {
396
- borderLeft: '4px solid ' + (route.color || '#2563eb'),
397
- padding: '12px',
398
- borderRadius: '8px',
399
- },
400
- });
401
- card.appendChild(SF.el('h3', { style: { margin: '0 0 8px' } }, route.technician_name || route.id));
402
- card.appendChild(SF.createTable({
403
- columns: ['Stops', 'Travel', 'Service'],
404
- rows: [[
405
- String((route.visits || []).length),
406
- formatDuration(stats.travelMinutes),
407
- formatDuration(stats.serviceMinutes),
408
- ]],
409
- }));
410
- card.appendChild(SF.createTable({
411
- columns: ['Territory', 'Late', 'Overtime'],
412
- rows: [[
413
- route.territory || '-',
414
- formatDuration(stats.lateMinutes),
415
- formatDuration(stats.overtimeMinutes),
416
- ]],
417
- }));
418
- routeCards.appendChild(card);
419
- });
420
- }
421
-
422
- function renderUnassignedCard(plan) {
423
- var assigned = assignedVisitSet(plan.technician_routes || []);
424
- var rows = (plan.service_visits || []).reduce(function (items, visit, idx) {
425
- if (assigned[idx]) return items;
426
- items.push([
427
- visit.customer || visit.name || visit.id,
428
- timeLabel(visit.earliest_minute) + '-' + timeLabel(visit.latest_minute),
429
- formatDuration(visit.duration_minutes || 0),
430
- ]);
431
- return items;
432
- }, []);
433
- if (!rows.length) return;
434
-
435
- var card = SF.el('div', {
436
- className: 'sf-section',
437
- style: {
438
- borderLeft: '4px solid #64748b',
439
- padding: '12px',
440
- borderRadius: '8px',
441
- },
442
- });
443
- card.appendChild(SF.el('h3', { style: { margin: '0 0 8px' } }, 'Unassigned visits'));
444
- card.appendChild(SF.createTable({
445
- columns: ['Visit', 'Window', 'Duration'],
446
- rows: rows,
447
- }));
448
- routeCards.appendChild(card);
449
  }
450
 
451
- function renderTimeline(plan) {
452
- var timelineConfig = buildTimelineConfig(plan);
453
- timelineContainer.innerHTML = '';
454
- timelineContainer.appendChild(SF.el('div', { className: 'sf-section' }, SF.createTable({
455
- columns: ['Route lanes', 'Window', 'Source'],
456
- rows: [[
457
- String((plan.technician_routes || []).length),
458
- '08:00-18:00',
459
- 'Latest SolverForge solution payload',
460
- ]],
461
- })));
462
-
463
- if (!routeTimeline) {
464
- routeTimeline = SF.rail.createTimeline(timelineConfig);
465
- } else {
466
- routeTimeline.setModel(timelineConfig.model);
467
- }
468
- timelineContainer.appendChild(routeTimeline.el);
469
- }
470
-
471
- function buildTimelineConfig(plan) {
472
- var lanes = (plan.technician_routes || []).map(function (route, routeIdx) {
473
- var items = routeSchedule(plan, route).map(function (entry, entryIdx) {
474
- return {
475
- id: 'route-' + routeIdx + '-visit-' + entryIdx,
476
- startMinute: entry.start,
477
- endMinute: entry.end,
478
- label: entry.visit.customer || entry.visit.name || entry.visit.id,
479
- meta: timeLabel(entry.start) + '-' + timeLabel(entry.end),
480
- tone: toneForRoute(routeIdx),
481
- };
482
- });
483
- var stats = routeStats(plan, route);
484
- return {
485
- id: route.id || ('route-' + routeIdx),
486
- label: route.technician_name || route.id || ('Technician ' + (routeIdx + 1)),
487
- mode: 'detailed',
488
- badges: routeBadges(stats),
489
- stats: [
490
- { label: 'Stops', value: (route.visits || []).length },
491
- { label: 'Travel', value: formatDuration(stats.travelMinutes) },
492
- { label: 'Service', value: formatDuration(stats.serviceMinutes) },
493
- ],
494
- items: items,
495
- };
496
- });
497
-
498
- return {
499
- label: 'Technician',
500
- labelWidth: 280,
501
- title: 'Bergamo Field Service Routes',
502
- subtitle: 'Ordered service visits per technician',
503
- model: {
504
- axis: buildDayAxis(),
505
- lanes: lanes,
506
- },
507
- };
508
- }
509
-
510
- function buildDayAxis() {
511
- var days = [{
512
- id: 'bergamo-day',
513
- label: 'Service day',
514
- subLabel: '08:00-18:00',
515
- startMinute: DAY_START,
516
- endMinute: DAY_END,
517
- }];
518
- var ticks = [];
519
- for (var minute = DAY_START; minute <= DAY_END; minute += 60) {
520
- ticks.push({
521
- id: 'tick-' + minute,
522
- minute: minute,
523
- label: timeLabel(minute),
524
- });
525
- }
526
- return {
527
- startMinute: DAY_START,
528
- endMinute: DAY_END,
529
- days: days,
530
- ticks: ticks,
531
- initialViewport: { startMinute: DAY_START, endMinute: DAY_END },
532
- };
533
- }
534
-
535
- function renderTables(plan) {
536
- tablesContainer.innerHTML = '';
537
- ['technician_routes', 'service_visits', 'locations'].forEach(function (key) {
538
- var rows = plan[key] || [];
539
- if (!rows.length) return;
540
- var columns = Object.keys(rows[0]).filter(function (column) {
541
- return column !== 'score' && column !== 'encoded_polyline';
542
- });
543
- var section = SF.el('div', { className: 'sf-section' });
544
- section.appendChild(SF.el('h3', null, title(key)));
545
- section.appendChild(SF.createTable({
546
- columns: columns,
547
- rows: rows.map(function (row) {
548
- return columns.map(function (column) {
549
- var value = row[column];
550
- if (value == null) return '-';
551
- if (Array.isArray(value)) return value.join(', ');
552
- return String(value);
553
- });
554
- }),
555
- }));
556
- tablesContainer.appendChild(section);
557
- });
558
- }
559
-
560
- function renderApiGuide() {
561
- apiGuideContainer.innerHTML = '';
562
- apiGuideContainer.appendChild(SF.createApiGuide({
563
- endpoints: [
564
- { method: 'GET', path: '/demo-data', description: 'Discover demo datasets', curl: buildCurl('GET', '/demo-data') },
565
- { method: 'GET', path: '/demo-data/' + (demoCatalog.defaultId || '{defaultId}'), description: 'Fetch Bergamo OSM-backed demo data', curl: buildCurl('GET', '/demo-data/' + (demoCatalog.defaultId || 'STANDARD')) },
566
- { method: 'POST', path: '/jobs', description: 'Create a retained solve job', curl: buildCurl('POST', '/jobs', true) },
567
- { method: 'GET', path: '/jobs/{id}/events', description: 'Stream typed SolverForge lifecycle events', curl: buildCurl('GET', '/jobs/{id}/events') },
568
- { method: 'GET', path: '/jobs/{id}/snapshot', description: 'Fetch latest route snapshot', curl: buildCurl('GET', '/jobs/{id}/snapshot') },
569
- { method: 'GET', path: '/jobs/{id}/analysis', description: 'Analyze the latest retained score', curl: buildCurl('GET', '/jobs/{id}/analysis') },
570
- ],
571
- }));
572
- }
573
-
574
- function buildCurl(method, path, json) {
575
- var parts = ['curl'];
576
- if (method && method !== 'GET') parts.push('-X', method);
577
- if (json) parts.push('-H', '"Content-Type: application/json"', '-d', '@plan.json');
578
- parts.push(window.location.origin + path);
579
- return parts.join(' ');
580
- }
581
-
582
- function routeSchedule(plan, route) {
583
- var visits = plan.service_visits || [];
584
- var entries = [];
585
- var clock = route.shift_start_minute;
586
- var previous = route.start_location_idx;
587
-
588
- (route.visits || []).forEach(function (visitIdx) {
589
- var visit = visits[visitIdx];
590
- if (!visit) return;
591
- var leg = legFor(plan, previous, visit.location_idx);
592
- clock += leg && leg.reachable ? Math.ceil((leg.duration_seconds || 0) / 60) : 0;
593
- if (clock < visit.earliest_minute) clock = visit.earliest_minute;
594
- var start = clock;
595
- var end = start + Math.max(0, visit.duration_minutes || 0);
596
- entries.push({ visit: visit, start: start, end: end });
597
- clock = end;
598
- previous = visit.location_idx;
599
- });
600
-
601
- return entries;
602
- }
603
-
604
- function routeStats(plan, route) {
605
- var visits = plan.service_visits || [];
606
- var travelMinutes = 0;
607
- var serviceMinutes = 0;
608
- var lateMinutes = 0;
609
- var overtimeMinutes = 0;
610
- var missingSkills = 0;
611
- var missingParts = 0;
612
- var unreachable = 0;
613
- var clock = route.shift_start_minute;
614
- var previous = route.start_location_idx;
615
-
616
- (route.visits || []).forEach(function (visitIdx) {
617
- var visit = visits[visitIdx];
618
- if (!visit) {
619
- unreachable += 1;
620
- return;
621
- }
622
- var leg = legFor(plan, previous, visit.location_idx);
623
- if (!leg || !leg.reachable) {
624
- unreachable += 1;
625
- } else {
626
- var legMinutes = Math.ceil((leg.duration_seconds || 0) / 60);
627
- travelMinutes += legMinutes;
628
- clock += legMinutes;
629
- }
630
- if (clock < visit.earliest_minute) clock = visit.earliest_minute;
631
- if (clock > visit.latest_minute) lateMinutes += clock - visit.latest_minute;
632
- if (!maskContains(route.skill_mask, visit.required_skill_mask)) missingSkills += 1;
633
- if (!maskContains(route.inventory_mask, visit.required_parts_mask)) missingParts += 1;
634
- serviceMinutes += Math.max(0, visit.duration_minutes || 0);
635
- clock += Math.max(0, visit.duration_minutes || 0);
636
- previous = visit.location_idx;
637
- });
638
-
639
- var returnLeg = legFor(plan, previous, route.end_location_idx);
640
- if (!returnLeg || !returnLeg.reachable) {
641
- unreachable += 1;
642
- } else {
643
- var returnMinutes = Math.ceil((returnLeg.duration_seconds || 0) / 60);
644
- travelMinutes += returnMinutes;
645
- clock += returnMinutes;
646
- }
647
-
648
- var routeMinutes = Math.max(0, clock - route.shift_start_minute);
649
- overtimeMinutes += Math.max(0, clock - route.shift_end_minute);
650
- overtimeMinutes += Math.max(0, routeMinutes - route.max_route_minutes);
651
-
652
- return {
653
- travelMinutes: travelMinutes,
654
- serviceMinutes: serviceMinutes,
655
- lateMinutes: lateMinutes,
656
- overtimeMinutes: overtimeMinutes,
657
- missingSkills: missingSkills,
658
- missingParts: missingParts,
659
- unreachable: unreachable,
660
- };
661
- }
662
-
663
- function drawLeg(plan, from, to, color) {
664
- var leg = legFor(plan, from, to);
665
- if (!leg || !leg.reachable || !leg.encoded_polyline) return;
666
- routeMap.drawEncodedRoute({ encoded: leg.encoded_polyline, color: color || '#2563eb' });
667
- }
668
-
669
- function legFor(plan, from, to) {
670
- var width = (plan.locations || []).length;
671
- var direct = (plan.travel_legs || [])[from * width + to];
672
- if (direct && direct.from_location_idx === from && direct.to_location_idx === to) {
673
- return direct;
674
- }
675
- return (plan.travel_legs || []).find(function (leg) {
676
- return leg.from_location_idx === from && leg.to_location_idx === to;
677
- });
678
- }
679
-
680
- function assignedVisitSet(routes) {
681
- var assigned = {};
682
- (routes || []).forEach(function (route) {
683
- (route.visits || []).forEach(function (visitIdx) {
684
- assigned[visitIdx] = route;
685
- });
686
- });
687
- return assigned;
688
  }
689
 
690
- function locationLat(location) {
691
- if (location.lat != null) return Number(location.lat);
692
- return Number(location.lat_e6 || 0) / 1000000;
693
- }
694
-
695
- function locationLng(location) {
696
- if (location.lng != null) return Number(location.lng);
697
- return Number(location.lng_e6 || 0) / 1000000;
698
- }
699
-
700
- function routeBadges(stats) {
701
- var badges = [];
702
- if (stats.unreachable) badges.push('Routing');
703
- if (stats.missingSkills) badges.push('Skills');
704
- if (stats.missingParts) badges.push('Parts');
705
- if (stats.lateMinutes) badges.push('Late');
706
- if (stats.overtimeMinutes) badges.push('Overtime');
707
- return badges.length ? badges : ['Feasible'];
708
- }
709
-
710
- function iconForVisit(visit) {
711
- if ((visit.required_skill_mask & 8) === 8) return 'fa-elevator';
712
- if ((visit.required_skill_mask & 4) === 4) return 'fa-faucet';
713
- if ((visit.required_skill_mask & 2) === 2) return 'fa-bolt';
714
- return 'fa-screwdriver-wrench';
715
- }
716
-
717
- function maskContains(available, required) {
718
- return ((available || 0) & (required || 0)) === (required || 0);
719
- }
720
-
721
- function toneForRoute(index) {
722
- return ['blue', 'emerald', 'amber', 'rose', 'violet', 'slate'][index % 6];
723
- }
724
-
725
- function formatDuration(minutes) {
726
- var value = Math.max(0, Math.round(minutes || 0));
727
- var h = Math.floor(value / 60);
728
- var m = value % 60;
729
- if (!h) return String(m) + 'm';
730
- return String(h) + 'h ' + String(m).padStart(2, '0') + 'm';
731
- }
732
-
733
- function timeLabel(minute) {
734
- var h = Math.floor(minute / 60);
735
- var m = minute % 60;
736
- return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
737
- }
738
-
739
- function clonePlan(data) {
740
- return JSON.parse(JSON.stringify(data));
741
  }
742
 
743
  function canSolve() {
@@ -745,7 +250,7 @@
745
  }
746
 
747
  function reportBootstrapError(err) {
748
- bootstrapError = describeError(err);
749
  bootstrapNotice.textContent = 'Demo data bootstrap failed: ' + bootstrapError;
750
  bootstrapNotice.style.display = '';
751
  app.dataset.bootstrapError = 'true';
@@ -760,12 +265,8 @@
760
  delete app.dataset.bootstrapError;
761
  }
762
 
763
- function describeError(err) {
764
- return err && err.message ? err.message : String(err || 'unknown error');
765
- }
766
-
767
  function updateSolveActionAvailability() {
768
- var solveButton = findHeaderButton('Solve');
769
  if (!solveButton) return;
770
  var disabled = !canSolve();
771
  solveButton.disabled = disabled;
@@ -775,14 +276,6 @@
775
  : '';
776
  }
777
 
778
- function findHeaderButton(label) {
779
- var buttons = header.querySelectorAll('button');
780
- for (var i = 0; i < buttons.length; i += 1) {
781
- if ((buttons[i].textContent || '').trim() === label) return buttons[i];
782
- }
783
- return null;
784
- }
785
-
786
  function syncLifecycleMarkers(meta) {
787
  var jobId = solver.getJobId();
788
  var snapshotRevision = solver.getSnapshotRevision();
@@ -796,21 +289,4 @@
796
  else delete app.dataset.lifecycleState;
797
  updateSolveActionAvailability();
798
  }
799
-
800
- function buildAnalysisHtml(analysis) {
801
- if (!analysis || !analysis.constraints) return '<p>No analysis available.</p>';
802
- var html = '<p><strong>Score:</strong> ' + SF.escHtml(analysis.score) + '</p>';
803
- html += '<table class="sf-table"><thead><tr><th>Constraint</th><th>Weight</th><th>Score</th><th>Matches</th></tr></thead><tbody>';
804
- analysis.constraints.forEach(function (constraint) {
805
- html += '<tr><td>' + SF.escHtml(constraint.name) + '</td><td>' + SF.escHtml(constraint.weight) + '</td><td>' + SF.escHtml(constraint.score) + '</td><td>' + String(constraint.matchCount || 0) + '</td></tr>';
806
- });
807
- html += '</tbody></table>';
808
- return html;
809
- }
810
-
811
- function title(text) {
812
- return String(text || '')
813
- .replace(/_/g, ' ')
814
- .replace(/\b\w/g, function (match) { return match.toUpperCase(); });
815
- }
816
  })();
 
1
+ /* app.js — lifecycle bootstrap for the Bergamo field service routing demo */
2
 
3
  (async function () {
4
  'use strict';
 
6
  var DAY_START = 8 * 60;
7
  var DAY_END = 18 * 60;
8
  var DEFAULT_CENTER = [45.698, 9.677];
9
+ var utils = window.FSR.utils;
10
 
11
  var config = await fetch('/sf-config.json').then(function (response) { return response.json(); });
12
  var uiModel = await fetch('/generated/ui-model.json').then(function (response) { return response.json(); });
 
19
  var bootstrapError = null;
20
  var lastAnalysis = null;
21
  var routeMap = null;
22
+ var renderer = null;
23
 
24
  var panels = {
25
  map: SF.el('div', { className: 'sf-content' }),
 
89
  style: { minHeight: '560px', borderRadius: '8px' },
90
  });
91
  var routeCards = SF.el('div', {
92
+ style: { display: 'grid', gap: '10px', alignContent: 'start', maxHeight: '560px', overflow: 'auto' },
 
 
 
 
 
 
93
  });
94
  mapShell.appendChild(mapContainer);
95
  mapShell.appendChild(routeCards);
 
97
  panels.map.appendChild(mapShell);
98
 
99
  var timelineContainer = SF.el('div');
 
 
100
  var tablesContainer = SF.el('div');
 
 
101
  var apiGuideContainer = SF.el('div');
102
+ panels.routes.appendChild(timelineContainer);
103
+ panels.data.appendChild(tablesContainer);
104
  panels.api.appendChild(apiGuideContainer);
105
 
106
  app.appendChild(panels.map);
 
115
  }));
116
 
117
  routeMap = SF.map.create({ container: 'fsr-map', center: DEFAULT_CENTER, zoom: 13 });
118
+ renderer = window.FSR.createRenderer({
119
+ SF: SF,
120
+ apiGuideContainer: apiGuideContainer,
121
+ dayEnd: DAY_END,
122
+ dayStart: DAY_START,
123
+ getDemoCatalog: function () { return demoCatalog; },
124
+ routeCards: routeCards,
125
+ routeMap: routeMap,
126
+ summaryContainer: summaryContainer,
127
+ tablesContainer: tablesContainer,
128
+ timelineContainer: timelineContainer,
129
+ });
130
 
131
  var analysisModal = SF.createModal({ title: 'Score Analysis', width: '760px' });
132
  var solver = SF.createSolver({
133
  backend: backend,
134
  statusBar: statusBar,
135
+ onProgress: function (meta) { syncLifecycleMarkers(meta); },
136
+ onPauseRequested: function (meta) { syncLifecycleMarkers(meta); },
 
 
 
 
137
  onSolution: function (snapshot, meta) {
138
+ renderSnapshot(snapshot);
 
 
139
  syncLifecycleMarkers(meta);
140
  },
141
  onPaused: function (snapshot, meta) {
142
+ renderSnapshot(snapshot);
 
 
 
 
 
143
  syncLifecycleMarkers(meta);
144
  },
145
+ onResumed: function (meta) { syncLifecycleMarkers(meta); },
146
  onCancelled: function (snapshot, meta) {
147
+ renderSnapshot(snapshot);
 
 
148
  syncLifecycleMarkers(meta);
149
  },
150
  onComplete: function (snapshot, meta) {
151
+ renderSnapshot(snapshot);
 
 
152
  syncLifecycleMarkers(meta);
153
  },
154
  onFailure: function (message, meta, snapshot, analysis) {
155
+ renderSnapshot(snapshot);
156
+ if (analysis) lastAnalysis = analysis;
 
 
 
 
157
  console.error('Solver job failed:', message);
158
  syncLifecycleMarkers(meta);
159
  },
 
167
  },
168
  });
169
 
170
+ renderer.renderApiGuide();
171
  updateSolveActionAvailability();
172
  bootstrapDemoData();
173
+ window.addEventListener('beforeunload', function () { renderer.destroy(); });
 
 
 
 
 
174
 
175
  function loadAndSolve() {
176
  if (solver.isRunning() || solver.getLifecycleState() === 'PAUSED' || !canSolve()) return;
 
182
  }
183
 
184
  function pauseSolve() {
185
+ solver.pause().then(function () { syncLifecycleMarkers(); }).catch(function (err) { console.error('Pause failed:', err); });
 
 
186
  }
187
 
188
  function resumeSolve() {
189
+ solver.resume().then(function () { syncLifecycleMarkers(); }).catch(function (err) { console.error('Resume failed:', err); });
 
 
190
  }
191
 
192
  function cancelSolve() {
193
+ solver.cancel().then(function () { syncLifecycleMarkers(); }).catch(function (err) { console.error('Cancel failed:', err); });
 
 
194
  }
195
 
196
  function openAnalysis() {
 
198
  solver.analyzeSnapshot()
199
  .then(function (analysis) {
200
  lastAnalysis = analysis;
201
+ analysisModal.setBody(renderer.buildAnalysisHtml(analysis));
202
  analysisModal.open();
203
  })
204
  .catch(function (err) { console.error('Analysis failed:', err); });
 
209
  if (!solver.getJobId() || state === 'IDLE' || state === 'PAUSED' || solver.isRunning()) {
210
  return Promise.resolve(null);
211
  }
212
+ return solver.delete().then(function () {
213
+ lastAnalysis = null;
214
+ syncLifecycleMarkers();
215
+ });
 
216
  }
217
 
218
  function resolvePlanForSolve() {
219
+ if (currentPlan) return Promise.resolve(utils.clonePlan(currentPlan));
220
+ if (!demoCatalog.defaultId) return Promise.reject(new Error('demo data catalog is unavailable'));
221
+ return utils.fetchDemoPlan(demoCatalog.defaultId);
 
 
 
 
222
  }
223
 
224
  function bootstrapDemoData() {
225
+ utils.fetchDemoCatalog()
226
  .then(function (catalog) {
227
  demoCatalog = catalog;
228
  clearBootstrapError();
229
+ renderer.renderApiGuide();
230
+ return utils.fetchDemoPlan(catalog.defaultId);
231
  })
232
  .then(function (plan) {
233
+ renderPlan(plan);
234
  updateSolveActionAvailability();
235
  })
236
+ .catch(function (err) { reportBootstrapError(err); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  }
238
 
239
+ function renderSnapshot(snapshot) {
240
+ if (snapshot && snapshot.solution) renderPlan(snapshot.solution);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  }
242
 
243
+ function renderPlan(plan) {
244
+ currentPlan = utils.clonePlan(plan);
245
+ renderer.renderAll(plan);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  }
247
 
248
  function canSolve() {
 
250
  }
251
 
252
  function reportBootstrapError(err) {
253
+ bootstrapError = err && err.message ? err.message : String(err || 'unknown error');
254
  bootstrapNotice.textContent = 'Demo data bootstrap failed: ' + bootstrapError;
255
  bootstrapNotice.style.display = '';
256
  app.dataset.bootstrapError = 'true';
 
265
  delete app.dataset.bootstrapError;
266
  }
267
 
 
 
 
 
268
  function updateSolveActionAvailability() {
269
+ var solveButton = utils.findHeaderButton(header, 'Solve');
270
  if (!solveButton) return;
271
  var disabled = !canSolve();
272
  solveButton.disabled = disabled;
 
276
  : '';
277
  }
278
 
 
 
 
 
 
 
 
 
279
  function syncLifecycleMarkers(meta) {
280
  var jobId = solver.getJobId();
281
  var snapshotRevision = solver.getSnapshotRevision();
 
289
  else delete app.dataset.lifecycleState;
290
  updateSolveActionAvailability();
291
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  })();
static/index.html CHANGED
@@ -16,6 +16,8 @@
16
  <script src="/sf/vendor/leaflet/leaflet.js"></script>
17
  <script src="/sf/sf.js"></script>
18
  <script src="/sf/modules/sf-map.js"></script>
 
 
19
  <script src="/app.js"></script>
20
  </body>
21
  </html>
 
16
  <script src="/sf/vendor/leaflet/leaflet.js"></script>
17
  <script src="/sf/sf.js"></script>
18
  <script src="/sf/modules/sf-map.js"></script>
19
+ <script src="/app-utils.js"></script>
20
+ <script src="/app-render.js"></script>
21
  <script src="/app.js"></script>
22
  </body>
23
  </html>