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:
2026-02-08 07:36:51 +01:00
parent 0f0f1cbf38
commit a4d360aae9
33 changed files with 2136 additions and 361 deletions

View 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`;
}

View File

@@ -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 }),
});
}