feat: comprehensive update with Signal, Copilot, themes, and PDF features

## Signal Messaging Integration
- Added SignalService for sending messages to drivers via Signal
- SignalMessage model for tracking message history
- Driver chat modal for real-time messaging
- Send schedule via Signal (ICS + PDF attachments)

## AI Copilot
- Natural language interface for VIP Coordinator
- Capabilities: create VIPs, schedule events, assign drivers
- Help and guidance for users
- Floating copilot button in UI

## Theme System
- Dark/light/system theme support
- Color scheme selection (blue, green, purple, orange, red)
- ThemeContext for global state
- AppearanceMenu in header

## PDF Schedule Export
- VIPSchedulePDF component for schedule generation
- PDF settings (header, footer, branding)
- Preview PDF in browser
- Settings stored in database

## Database Migrations
- add_signal_messages: SignalMessage model
- add_pdf_settings: Settings model for PDF config
- add_reminder_tracking: lastReminderSent for events
- make_driver_phone_optional: phone field nullable

## Event Management
- Event status service for automated updates
- IN_PROGRESS/COMPLETED status tracking
- Reminder tracking for notifications

## UI/UX Improvements
- Driver schedule modal
- Improved My Schedule page
- Better error handling and loading states
- Responsive design improvements

## Other Changes
- AGENT_TEAM.md documentation
- Seed data improvements
- Ability factory updates
- Driver profile page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 19:30:41 +01:00
parent 2d842ed294
commit 3b0b1205df
84 changed files with 12330 additions and 2103 deletions

View File

@@ -0,0 +1,626 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Department, ArrivalMode, EventType, EventStatus, VehicleType, VehicleStatus } from '@prisma/client';
@Injectable()
export class SeedService {
private readonly logger = new Logger(SeedService.name);
constructor(private prisma: PrismaService) {}
/**
* Clear all data using fast deleteMany operations
*/
async clearAllData() {
const start = Date.now();
// Delete in order to respect foreign key constraints
const results = await this.prisma.$transaction([
this.prisma.signalMessage.deleteMany(),
this.prisma.scheduleEvent.deleteMany(),
this.prisma.flight.deleteMany(),
this.prisma.vehicle.deleteMany(),
this.prisma.driver.deleteMany(),
this.prisma.vIP.deleteMany(),
]);
const elapsed = Date.now() - start;
this.logger.log(`Cleared all data in ${elapsed}ms`);
return {
success: true,
elapsed: `${elapsed}ms`,
deleted: {
messages: results[0].count,
events: results[1].count,
flights: results[2].count,
vehicles: results[3].count,
drivers: results[4].count,
vips: results[5].count,
},
};
}
/**
* Generate all test data in a single fast transaction
*/
async generateAllTestData(clearFirst: boolean = true) {
const start = Date.now();
if (clearFirst) {
await this.clearAllData();
}
// Create all entities in a transaction
const result = await this.prisma.$transaction(async (tx) => {
// 1. Create VIPs
const vipData = this.getVIPData();
await tx.vIP.createMany({ data: vipData });
const vips = await tx.vIP.findMany({ orderBy: { createdAt: 'asc' } });
this.logger.log(`Created ${vips.length} VIPs`);
// 2. Create Drivers with shifts
const driverData = this.getDriverData();
await tx.driver.createMany({ data: driverData });
const drivers = await tx.driver.findMany({ orderBy: { createdAt: 'asc' } });
this.logger.log(`Created ${drivers.length} drivers`);
// 3. Create Vehicles
const vehicleData = this.getVehicleData();
await tx.vehicle.createMany({ data: vehicleData });
const vehicles = await tx.vehicle.findMany({ orderBy: { createdAt: 'asc' } });
this.logger.log(`Created ${vehicles.length} vehicles`);
// 4. Create Flights for VIPs arriving by flight
const flightVips = vips.filter(v => v.arrivalMode === 'FLIGHT');
const flightData = this.getFlightData(flightVips);
await tx.flight.createMany({ data: flightData });
const flights = await tx.flight.findMany();
this.logger.log(`Created ${flights.length} flights`);
// 5. Create Events with dynamic times relative to NOW
const eventData = this.getEventData(vips, drivers, vehicles);
await tx.scheduleEvent.createMany({ data: eventData });
const events = await tx.scheduleEvent.findMany();
this.logger.log(`Created ${events.length} events`);
return { vips, drivers, vehicles, flights, events };
});
const elapsed = Date.now() - start;
this.logger.log(`Generated all test data in ${elapsed}ms`);
return {
success: true,
elapsed: `${elapsed}ms`,
created: {
vips: result.vips.length,
drivers: result.drivers.length,
vehicles: result.vehicles.length,
flights: result.flights.length,
events: result.events.length,
},
};
}
/**
* Generate only dynamic events (uses existing VIPs/drivers/vehicles)
*/
async generateDynamicEvents() {
const start = Date.now();
// Clear existing events
await this.prisma.scheduleEvent.deleteMany();
// Get existing entities
const [vips, drivers, vehicles] = await Promise.all([
this.prisma.vIP.findMany({ where: { deletedAt: null } }),
this.prisma.driver.findMany({ where: { deletedAt: null } }),
this.prisma.vehicle.findMany({ where: { deletedAt: null } }),
]);
if (vips.length === 0) {
return { success: false, error: 'No VIPs found. Generate full test data first.' };
}
// Create events
const eventData = this.getEventData(vips, drivers, vehicles);
await this.prisma.scheduleEvent.createMany({ data: eventData });
const elapsed = Date.now() - start;
return {
success: true,
elapsed: `${elapsed}ms`,
created: { events: eventData.length },
};
}
// ============================================================
// DATA GENERATORS
// ============================================================
private getVIPData() {
return [
// OFFICE_OF_DEVELOPMENT (10 VIPs) - Corporate sponsors, foundations, major donors
{ name: 'Sarah Chen', organization: 'Microsoft Corporation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Executive VP - prefers quiet vehicles. Allergic to peanuts.' },
{ name: 'Marcus Johnson', organization: 'The Coca-Cola Company', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Bringing spouse. Needs wheelchair accessible transport.' },
{ name: 'Jennifer Wu', organization: 'JPMorgan Chase Foundation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Major donor - $500K pledge. VIP treatment essential.' },
{ name: 'Roberto Gonzalez', organization: 'AT&T Inc', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'First time visitor. Interested in STEM programs.' },
{ name: 'Priya Sharma', organization: 'Google LLC', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Vegetarian meals required. Interested in technology merit badges.' },
{ name: 'David Okonkwo', organization: 'Bank of America', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: false, venueTransport: true, notes: 'Has rental car for airport. Needs venue transport only.' },
{ name: 'Maria Rodriguez', organization: 'Walmart Foundation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(120), notes: 'Driving from nearby hotel. Call when 30 min out.' },
{ name: 'Yuki Tanaka', organization: 'Honda Motor Company', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Japanese executive - interpreter may be needed.' },
{ name: 'Thomas Anderson', organization: 'Verizon Communications', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: false, notes: 'Will use personal driver after airport pickup.' },
{ name: 'Isabella Costa', organization: 'Target Corporation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: false, venueTransport: true, notes: 'Taking rideshare from airport. Venue transport needed.' },
// ADMIN (10 VIPs) - BSA Leadership and Staff
{ name: 'Roger A. Krone', organization: 'BSA National President', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'HIGHEST PRIORITY VIP. Security detail traveling with him.' },
{ name: 'Emily Richardson', organization: 'BSA Chief Scout Executive', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: false, expectedArrival: this.relativeTime(60), notes: 'Has assigned BSA vehicle. No transport needed.' },
{ name: 'Dr. Maya Krishnan', organization: 'BSA National Director of Program', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: false, expectedArrival: this.relativeTime(180), notes: 'Carpooling with regional directors.' },
{ name: "James O'Brien", organization: 'BSA Northeast Regional Director', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Traveling with 2 staff members.' },
{ name: 'Fatima Al-Rahman', organization: 'BSA Western Region Executive', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Halal meals required. Prayer room access needed.' },
{ name: 'William Zhang', organization: 'BSA Southern Region Council', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Bringing presentation equipment - need vehicle with cargo space.' },
{ name: 'Sophie Laurent', organization: 'BSA National Volunteer Training', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(240), notes: 'Training materials in personal vehicle.' },
{ name: 'Alexander Volkov', organization: 'BSA High Adventure Director', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: false, notes: 'Outdoor enthusiast - prefers walking when possible.' },
{ name: 'Dr. Aisha Patel', organization: 'BSA STEM & Innovation Programs', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(90), notes: 'Demo equipment for STEM showcase. Fragile items!' },
{ name: 'Henrik Larsson', organization: 'BSA International Commissioner', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(150), notes: 'Visiting from Sweden. International guest protocols apply.' },
];
}
private getDriverData() {
const now = new Date();
const shiftStart = new Date(now);
shiftStart.setHours(6, 0, 0, 0);
const shiftEnd = new Date(now);
shiftEnd.setHours(22, 0, 0, 0);
const lateShiftStart = new Date(now);
lateShiftStart.setHours(14, 0, 0, 0);
const lateShiftEnd = new Date(now);
lateShiftEnd.setHours(23, 59, 0, 0);
return [
{ name: 'Michael Thompson', phone: '555-0101', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
{ name: 'Lisa Martinez', phone: '555-0102', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
{ name: 'David Kim', phone: '555-0103', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
{ name: 'Amanda Washington', phone: '555-0104', department: Department.ADMIN, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
{ name: 'Carlos Hernandez', phone: '555-0105', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: false, shiftStartTime: lateShiftStart, shiftEndTime: lateShiftEnd }, // Off duty until 2pm
{ name: 'Jessica Lee', phone: '555-0106', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
{ name: 'Brandon Jackson', phone: '555-0107', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: lateShiftStart, shiftEndTime: lateShiftEnd },
{ name: 'Nicole Brown', phone: '555-0108', department: Department.ADMIN, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
];
}
private getVehicleData() {
return [
{ name: 'Blue Van', type: VehicleType.VAN, licensePlate: 'VAN-001', seatCapacity: 12, status: VehicleStatus.AVAILABLE, notes: 'Primary transport van with wheelchair accessibility' },
{ name: 'Suburban #1', type: VehicleType.SUV, licensePlate: 'SUV-101', seatCapacity: 7, status: VehicleStatus.AVAILABLE, notes: 'Leather interior, ideal for VIP comfort' },
{ name: 'Golf Cart Alpha', type: VehicleType.GOLF_CART, licensePlate: 'GC-A', seatCapacity: 6, status: VehicleStatus.AVAILABLE, notes: 'Quick campus transport, good for short distances' },
{ name: 'Red Van', type: VehicleType.VAN, licensePlate: 'VAN-002', seatCapacity: 8, status: VehicleStatus.AVAILABLE, notes: 'Standard transport van' },
{ name: 'Scout Bus', type: VehicleType.BUS, licensePlate: 'BUS-001', seatCapacity: 25, status: VehicleStatus.AVAILABLE, notes: 'Large group transport, AC equipped' },
{ name: 'Suburban #2', type: VehicleType.SUV, licensePlate: 'SUV-102', seatCapacity: 7, status: VehicleStatus.AVAILABLE, notes: 'Backup VIP transport' },
{ name: 'Golf Cart Bravo', type: VehicleType.GOLF_CART, licensePlate: 'GC-B', seatCapacity: 4, status: VehicleStatus.AVAILABLE, notes: 'Quick on-site transport' },
{ name: 'Equipment Truck', type: VehicleType.TRUCK, licensePlate: 'TRK-001', seatCapacity: 3, status: VehicleStatus.MAINTENANCE, notes: 'For equipment and supply runs - currently in maintenance' },
{ name: 'Executive Sedan', type: VehicleType.SEDAN, licensePlate: 'SED-001', seatCapacity: 4, status: VehicleStatus.AVAILABLE, notes: 'Premium sedan for executive VIPs' },
{ name: 'Golf Cart Charlie', type: VehicleType.GOLF_CART, licensePlate: 'GC-C', seatCapacity: 4, status: VehicleStatus.AVAILABLE, notes: 'Backup golf cart' },
];
}
private getFlightData(vips: any[]) {
const flights: any[] = [];
const airlines = ['AA', 'UA', 'DL', 'SW', 'AS', 'JB'];
const origins = ['JFK', 'LAX', 'ORD', 'DFW', 'ATL', 'SFO', 'SEA', 'BOS', 'DEN', 'MIA'];
const destination = 'SLC'; // Assuming Salt Lake City for the Jamboree
vips.forEach((vip, index) => {
const airline = airlines[index % airlines.length];
const flightNum = `${airline}${1000 + index * 123}`;
const origin = origins[index % origins.length];
// Arrival flight - times relative to now
const arrivalOffset = (index % 8) * 30 - 60; // -60 to +150 minutes from now
const scheduledArrival = this.relativeTime(arrivalOffset);
const scheduledDeparture = new Date(scheduledArrival.getTime() - 3 * 60 * 60 * 1000); // 3 hours before
// Some flights are delayed, some landed, some on time
let status = 'scheduled';
let actualArrival = null;
if (arrivalOffset < -30) {
status = 'landed';
actualArrival = new Date(scheduledArrival.getTime() + (Math.random() * 20 - 10) * 60000);
} else if (arrivalOffset < 0) {
status = 'landing';
} else if (index % 5 === 0) {
status = 'delayed';
}
flights.push({
vipId: vip.id,
flightNumber: flightNum,
flightDate: new Date(),
segment: 1,
departureAirport: origin,
arrivalAirport: destination,
scheduledDeparture,
scheduledArrival,
actualArrival,
status,
});
// Some VIPs have connecting flights (segment 2)
if (index % 4 === 0) {
const connectOrigin = origins[(index + 3) % origins.length];
flights.push({
vipId: vip.id,
flightNumber: `${airline}${500 + index}`,
flightDate: new Date(),
segment: 2,
departureAirport: connectOrigin,
arrivalAirport: origin,
scheduledDeparture: new Date(scheduledDeparture.getTime() - 4 * 60 * 60 * 1000),
scheduledArrival: new Date(scheduledDeparture.getTime() - 1 * 60 * 60 * 1000),
status: 'landed',
});
}
});
return flights;
}
private getEventData(vips: any[], drivers: any[], vehicles: any[]) {
const events: any[] = [];
const now = new Date();
// Track vehicle assignments to avoid conflicts
// Map of vehicleId -> array of { start: Date, end: Date }
const vehicleSchedule: Map<string, Array<{ start: Date; end: Date }>> = new Map();
const driverSchedule: Map<string, Array<{ start: Date; end: Date }>> = new Map();
// Initialize schedules
vehicles.forEach(v => vehicleSchedule.set(v.id, []));
drivers.forEach(d => driverSchedule.set(d.id, []));
// Check if a time slot conflicts with existing assignments
const hasConflict = (schedule: Array<{ start: Date; end: Date }>, start: Date, end: Date): boolean => {
return schedule.some(slot =>
(start < slot.end && end > slot.start) // Overlapping
);
};
// Find an available vehicle for a time slot
const findAvailableVehicle = (start: Date, end: Date, preferredIndex: number): any | null => {
if (vehicles.length === 0) return null;
// Try preferred vehicle first
const preferred = vehicles[preferredIndex % vehicles.length];
const preferredSchedule = vehicleSchedule.get(preferred.id) || [];
if (!hasConflict(preferredSchedule, start, end)) {
preferredSchedule.push({ start, end });
return preferred;
}
// Try other vehicles
for (const vehicle of vehicles) {
const schedule = vehicleSchedule.get(vehicle.id) || [];
if (!hasConflict(schedule, start, end)) {
schedule.push({ start, end });
return vehicle;
}
}
return null; // No available vehicle
};
// Find an available driver for a time slot
const findAvailableDriver = (start: Date, end: Date, preferredIndex: number): any | null => {
if (drivers.length === 0) return null;
// Try preferred driver first
const preferred = drivers[preferredIndex % drivers.length];
const preferredSchedule = driverSchedule.get(preferred.id) || [];
if (!hasConflict(preferredSchedule, start, end)) {
preferredSchedule.push({ start, end });
return preferred;
}
// Try other drivers
for (const driver of drivers) {
const schedule = driverSchedule.get(driver.id) || [];
if (!hasConflict(schedule, start, end)) {
schedule.push({ start, end });
return driver;
}
}
return null; // No available driver
};
vips.forEach((vip, vipIndex) => {
// ============================================================
// CREATE VARIED EVENTS RELATIVE TO NOW
// ============================================================
// Event pattern based on VIP index to create variety:
// - Some VIPs have events IN_PROGRESS
// - Some have events starting VERY soon (5-15 min)
// - Some have events starting soon (30-60 min)
// - Some have just-completed events
// - All have future events throughout the day
const eventPattern = vipIndex % 5;
switch (eventPattern) {
case 0: { // IN_PROGRESS airport pickup
const start = this.relativeTime(-25);
const end = this.relativeTime(15);
const driver = findAvailableDriver(start, end, vipIndex);
const vehicle = findAvailableVehicle(start, end, vipIndex);
events.push({
vipIds: [vip.id],
driverId: driver?.id,
vehicleId: vehicle?.id,
title: `Airport Pickup - ${vip.name}`,
type: EventType.TRANSPORT,
status: EventStatus.IN_PROGRESS,
pickupLocation: 'Airport - Terminal B',
dropoffLocation: 'Main Gate Registration',
startTime: start,
endTime: end,
actualStartTime: this.relativeTime(-23),
description: `ACTIVE: Driver en route with ${vip.name} from airport`,
notes: 'VIP collected from arrivals. ETA 15 minutes.',
});
break;
}
case 1: { // COMPLETED event
const start = this.relativeTime(-90);
const end = this.relativeTime(-45);
const driver = findAvailableDriver(start, end, vipIndex);
const vehicle = findAvailableVehicle(start, end, vipIndex);
events.push({
vipIds: [vip.id],
driverId: driver?.id,
vehicleId: vehicle?.id,
title: `Airport Pickup - ${vip.name}`,
type: EventType.TRANSPORT,
status: EventStatus.COMPLETED,
pickupLocation: 'Airport - Terminal A',
dropoffLocation: 'VIP Lodge',
startTime: start,
endTime: end,
actualStartTime: this.relativeTime(-88),
actualEndTime: this.relativeTime(-42),
description: `Completed pickup for ${vip.name}`,
});
break;
}
case 2: { // Starting in 5-10 minutes (URGENT)
const start = this.relativeTime(7);
const end = this.relativeTime(22);
const driver = findAvailableDriver(start, end, vipIndex);
const vehicle = findAvailableVehicle(start, end, vipIndex);
events.push({
vipIds: [vip.id],
driverId: driver?.id,
vehicleId: vehicle?.id,
title: `URGENT: Transport to Opening Ceremony - ${vip.name}`,
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
pickupLocation: 'VIP Lodge',
dropoffLocation: 'Main Arena - VIP Entrance',
startTime: start,
endTime: end,
description: `Pick up ${vip.name} for Opening Ceremony - STARTS SOON!`,
notes: 'Driver should be at pickup location NOW',
});
break;
}
case 3: { // Starting in 30-45 min
const start = this.relativeTime(35);
const end = this.relativeTime(50);
const driver = findAvailableDriver(start, end, vipIndex);
const vehicle = findAvailableVehicle(start, end, vipIndex);
events.push({
vipIds: [vip.id],
driverId: driver?.id,
vehicleId: vehicle?.id,
title: `VIP Lodge Transfer - ${vip.name}`,
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
pickupLocation: 'Registration Tent',
dropoffLocation: 'VIP Lodge - Building A',
startTime: start,
endTime: end,
description: `Transfer ${vip.name} to VIP accommodation after registration`,
});
break;
}
case 4: // In-progress MEETING (no driver/vehicle needed)
events.push({
vipIds: [vip.id],
title: `Donor Briefing Meeting`,
type: EventType.MEETING,
status: EventStatus.IN_PROGRESS,
location: 'Conference Center - Room 101',
startTime: this.relativeTime(-20),
endTime: this.relativeTime(25),
actualStartTime: this.relativeTime(-18),
description: `${vip.name} in donor briefing with development team`,
});
break;
}
// ============================================================
// ADD STANDARD DAY EVENTS FOR ALL VIPS
// ============================================================
// Upcoming meal (1-2 hours out) - no driver/vehicle needed
events.push({
vipIds: [vip.id],
title: vipIndex % 2 === 0 ? 'VIP Luncheon' : 'VIP Breakfast',
type: EventType.MEAL,
status: EventStatus.SCHEDULED,
location: 'VIP Dining Pavilion',
startTime: this.relativeTime(60 + (vipIndex % 4) * 15),
endTime: this.relativeTime(120 + (vipIndex % 4) * 15),
description: `Catered meal for ${vip.name} with other VIP guests`,
});
// Transport to main event (2-3 hours out)
{
const start = this.relativeTime(150 + vipIndex * 5);
const end = this.relativeTime(165 + vipIndex * 5);
const driver = findAvailableDriver(start, end, vipIndex + 3);
const vehicle = findAvailableVehicle(start, end, vipIndex + 2);
events.push({
vipIds: [vip.id],
driverId: driver?.id,
vehicleId: vehicle?.id,
title: `Transport to Scout Exhibition`,
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
pickupLocation: 'VIP Lodge',
dropoffLocation: 'Exhibition Grounds',
startTime: start,
endTime: end,
description: `Transport ${vip.name} to Scout Exhibition area`,
});
}
// Main event (3-4 hours out) - no driver/vehicle needed
events.push({
vipIds: [vip.id],
title: 'Scout Skills Exhibition',
type: EventType.EVENT,
status: EventStatus.SCHEDULED,
location: 'Exhibition Grounds - Zone A',
startTime: this.relativeTime(180 + vipIndex * 3),
endTime: this.relativeTime(270 + vipIndex * 3),
description: `${vip.name} tours Scout exhibitions and demonstrations`,
});
// Evening dinner (5-6 hours out) - no driver/vehicle needed
events.push({
vipIds: [vip.id],
title: 'Gala Dinner',
type: EventType.MEAL,
status: EventStatus.SCHEDULED,
location: 'Grand Ballroom',
startTime: this.relativeTime(360),
endTime: this.relativeTime(480),
description: `Black-tie dinner event with ${vip.name} and other distinguished guests`,
});
// Next day departure (tomorrow morning)
if (vip.arrivalMode === 'FLIGHT') {
const start = this.relativeTime(60 * 24 + 120 + vipIndex * 20);
const end = this.relativeTime(60 * 24 + 165 + vipIndex * 20);
const driver = findAvailableDriver(start, end, vipIndex + 1);
const vehicle = findAvailableVehicle(start, end, vipIndex + 1);
events.push({
vipIds: [vip.id],
driverId: driver?.id,
vehicleId: vehicle?.id,
title: `Airport Departure - ${vip.name}`,
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
pickupLocation: 'VIP Lodge',
dropoffLocation: 'Airport - Departures',
startTime: start,
endTime: end,
description: `Transport ${vip.name} to airport for departure flight`,
notes: 'Confirm flight status before pickup',
});
}
});
// ============================================================
// ADD MULTI-VIP GROUP EVENTS
// ============================================================
if (vips.length >= 4) {
// Group transport with multiple VIPs
{
const start = this.relativeTime(45);
const end = this.relativeTime(60);
const driver = findAvailableDriver(start, end, 0);
// Find a large vehicle (bus or van with capacity >= 8)
const largeVehicle = vehicles.find(v =>
v.seatCapacity >= 8 && !hasConflict(vehicleSchedule.get(v.id) || [], start, end)
);
if (largeVehicle) {
vehicleSchedule.get(largeVehicle.id)?.push({ start, end });
}
events.push({
vipIds: [vips[0].id, vips[1].id, vips[2].id],
driverId: driver?.id,
vehicleId: largeVehicle?.id,
title: 'Group Transport - Leadership Briefing',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
pickupLocation: 'VIP Lodge - Main Entrance',
dropoffLocation: 'National HQ Building',
startTime: start,
endTime: end,
description: 'Multi-VIP transport for leadership briefing session',
notes: 'IMPORTANT: Picking up 3 VIPs - use large vehicle',
});
}
// Another group event
if (vips.length >= 5) {
const start = this.relativeTime(90);
const end = this.relativeTime(110);
const driver = findAvailableDriver(start, end, 1);
const vehicle = findAvailableVehicle(start, end, 1);
events.push({
vipIds: [vips[3].id, vips[4].id],
driverId: driver?.id,
vehicleId: vehicle?.id,
title: 'Group Transport - Media Tour',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
pickupLocation: 'Media Center',
dropoffLocation: 'Historical Site',
startTime: start,
endTime: end,
description: 'VIP media tour with photo opportunities',
});
}
}
// ============================================================
// ADD SOME CANCELLED EVENTS FOR REALISM
// ============================================================
if (vips.length >= 6) {
events.push({
vipIds: [vips[5].id],
title: 'Private Meeting - CANCELLED',
type: EventType.MEETING,
status: EventStatus.CANCELLED,
location: 'Conference Room B',
startTime: this.relativeTime(200),
endTime: this.relativeTime(260),
description: 'Meeting cancelled due to schedule conflict',
notes: 'VIP requested reschedule for tomorrow',
});
}
return events;
}
// ============================================================
// HELPER METHODS
// ============================================================
/**
* Get a date relative to now
* @param minutesOffset - Minutes from now (negative = past, positive = future)
*/
private relativeTime(minutesOffset: number): Date {
return new Date(Date.now() + minutesOffset * 60 * 1000);
}
}