feat: add PDF reports, timezone management, GPS QR codes, and fix GPS tracking gaps
Issue #1: QR button on GPS Devices tab for re-enrollment Issue #2: App-wide timezone setting with TimezoneContext, useFormattedDate hook, and admin timezone selector. All date displays now respect the configured timezone. Issue #3: PDF export for Accountability Roster using @react-pdf/renderer with professional styling matching VIPSchedulePDF. Added Signal send button. Issue #4: Fixed GPS "teleporting" gaps - syncPositions now fetches position history per device instead of only latest position. Changed cron to every 30s, added unique constraint on deviceId+timestamp for deduplication, lowered min interval to 10s. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
118
frontend/src/lib/journeyUtils.ts
Normal file
118
frontend/src/lib/journeyUtils.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Flight, Journey, Layover, LayoverRiskLevel } from '@/types';
|
||||
|
||||
function getEffectiveArrival(flight: Flight): Date | null {
|
||||
const t = flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
|
||||
return t ? new Date(t) : null;
|
||||
}
|
||||
|
||||
function getEffectiveDeparture(flight: Flight): Date | null {
|
||||
const t = flight.actualDeparture || flight.estimatedDeparture || flight.scheduledDeparture;
|
||||
return t ? new Date(t) : null;
|
||||
}
|
||||
|
||||
function computeLayoverRisk(effectiveMinutes: number, scheduledMinutes: number): LayoverRiskLevel {
|
||||
if (effectiveMinutes < 0) return 'missed';
|
||||
if (effectiveMinutes < 30) return 'critical';
|
||||
if (effectiveMinutes < 60) return 'warning';
|
||||
if (scheduledMinutes > 0) return 'ok';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function computeLayover(arriving: Flight, departing: Flight, index: number): Layover {
|
||||
const scheduledArr = arriving.scheduledArrival ? new Date(arriving.scheduledArrival) : null;
|
||||
const scheduledDep = departing.scheduledDeparture ? new Date(departing.scheduledDeparture) : null;
|
||||
const effectiveArr = getEffectiveArrival(arriving);
|
||||
const effectiveDep = getEffectiveDeparture(departing);
|
||||
|
||||
const scheduledMinutes = scheduledArr && scheduledDep
|
||||
? (scheduledDep.getTime() - scheduledArr.getTime()) / 60000
|
||||
: 0;
|
||||
|
||||
const effectiveMinutes = effectiveArr && effectiveDep
|
||||
? (effectiveDep.getTime() - effectiveArr.getTime()) / 60000
|
||||
: scheduledMinutes;
|
||||
|
||||
return {
|
||||
airport: arriving.arrivalAirport,
|
||||
afterSegmentIndex: index,
|
||||
scheduledMinutes: Math.round(scheduledMinutes),
|
||||
effectiveMinutes: Math.round(effectiveMinutes),
|
||||
risk: computeLayoverRisk(effectiveMinutes, scheduledMinutes),
|
||||
};
|
||||
}
|
||||
|
||||
function computeEffectiveStatus(flights: Flight[]): { effectiveStatus: string; currentSegmentIndex: number } {
|
||||
// Critical statuses on any segment take priority
|
||||
for (let i = 0; i < flights.length; i++) {
|
||||
const s = flights[i].status?.toLowerCase();
|
||||
if (s === 'cancelled' || s === 'diverted' || s === 'incident') {
|
||||
return { effectiveStatus: s, currentSegmentIndex: i };
|
||||
}
|
||||
}
|
||||
|
||||
// Find first non-terminal segment (the "active" one)
|
||||
for (let i = 0; i < flights.length; i++) {
|
||||
const s = flights[i].status?.toLowerCase();
|
||||
const isTerminal = s === 'landed' || !!flights[i].actualArrival;
|
||||
if (!isTerminal) {
|
||||
return { effectiveStatus: s || 'scheduled', currentSegmentIndex: i };
|
||||
}
|
||||
}
|
||||
|
||||
// All segments landed
|
||||
const last = flights.length - 1;
|
||||
return { effectiveStatus: flights[last].status?.toLowerCase() || 'landed', currentSegmentIndex: last };
|
||||
}
|
||||
|
||||
export function groupFlightsIntoJourneys(flights: Flight[]): Journey[] {
|
||||
const byVip = new Map<string, Flight[]>();
|
||||
for (const flight of flights) {
|
||||
const group = byVip.get(flight.vipId) || [];
|
||||
group.push(flight);
|
||||
byVip.set(flight.vipId, group);
|
||||
}
|
||||
|
||||
const journeys: Journey[] = [];
|
||||
|
||||
for (const [vipId, vipFlights] of byVip) {
|
||||
// Sort chronologically by departure time, then segment as tiebreaker
|
||||
const sorted = [...vipFlights].sort((a, b) => {
|
||||
const depA = a.scheduledDeparture || a.flightDate;
|
||||
const depB = b.scheduledDeparture || b.flightDate;
|
||||
const timeDiff = new Date(depA).getTime() - new Date(depB).getTime();
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return a.segment - b.segment;
|
||||
});
|
||||
|
||||
const layovers: Layover[] = [];
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
layovers.push(computeLayover(sorted[i], sorted[i + 1], i));
|
||||
}
|
||||
|
||||
const { effectiveStatus, currentSegmentIndex } = computeEffectiveStatus(sorted);
|
||||
|
||||
journeys.push({
|
||||
vipId,
|
||||
vip: sorted[0]?.vip,
|
||||
flights: sorted,
|
||||
layovers,
|
||||
effectiveStatus,
|
||||
currentSegmentIndex,
|
||||
hasLayoverRisk: layovers.some(l => l.risk === 'warning' || l.risk === 'critical' || l.risk === 'missed'),
|
||||
origin: sorted[0]?.departureAirport,
|
||||
destination: sorted[sorted.length - 1]?.arrivalAirport,
|
||||
isMultiSegment: sorted.length > 1,
|
||||
});
|
||||
}
|
||||
|
||||
return journeys;
|
||||
}
|
||||
|
||||
export function formatLayoverDuration(minutes: number): string {
|
||||
if (minutes < 0) return `${Math.abs(minutes)}min late`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (h === 0) return `${m}min`;
|
||||
if (m === 0) return `${h}h`;
|
||||
return `${h}h ${m}min`;
|
||||
}
|
||||
@@ -5,16 +5,17 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date): string {
|
||||
export function formatDate(date: string | Date, timeZone?: string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(timeZone && { timeZone }),
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateTime(date: string | Date): string {
|
||||
export function formatDateTime(date: string | Date, timeZone?: string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
@@ -22,13 +23,15 @@ export function formatDateTime(date: string | Date): string {
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
...(timeZone && { timeZone }),
|
||||
});
|
||||
}
|
||||
|
||||
export function formatTime(date: string | Date): string {
|
||||
export function formatTime(date: string | Date, timeZone?: string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
...(timeZone && { timeZone }),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user