Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 45 additions & 36 deletions src/lib/components/airport-details/AirportStatsCard.svelte
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
<script lang="ts">
import { PlaneLanding, PlaneTakeoff } from '@o7/icon/lucide';
import NumberFlow from '@number-flow/svelte';
import { PlaneLanding, PlaneTakeoff } from '@o7/icon/lucide';

import type { FlightData } from '$lib/utils';
import { formatAsFlightDate } from '$lib/utils/datetime';
import { cn, type FlightData } from '$lib/utils';
import { getAirportVisitSummary } from '$lib/utils/data/airport-visits';

let {
flights,
airportId,
airlineCount,
now,
}: {
flights: FlightData[];
airportId: number;
airlineCount: number;
now: Date;
} = $props();

const departures = $derived(
Expand All @@ -32,28 +34,9 @@
return set.size;
});

const mostRecent = $derived.by(() => {
let best: FlightData | null = null;
let bestTs = -Infinity;
for (const f of flights) {
const ts = f.date?.getTime();
if (ts && ts > bestTs) {
bestTs = ts;
best = f;
}
}
return best;
});

const lastVisitLabel = $derived.by(() => {
if (!mostRecent?.date) return null;
return formatAsFlightDate(
mostRecent.date,
mostRecent.datePrecision ?? 'day',
false,
true,
);
});
const visits = $derived(getAirportVisitSummary(flights, airportId, now));
const lastVisitLabel = $derived(visits.last);
const nextVisitLabel = $derived(visits.next);
</script>

<section class="px-4 py-4">
Expand Down Expand Up @@ -86,27 +69,53 @@
</div>
</div>

{#if lastVisitLabel && nextVisitLabel}
<div
class="flex flex-wrap items-center gap-x-2 gap-y-1 mt-3 text-xs text-muted-foreground"
>
<span>
last visit <span class="text-foreground">{lastVisitLabel}</span>
</span>
<span class="inline-flex items-center gap-2">
<span aria-hidden="true">·</span>
<span>
next visit <span class="text-foreground">{nextVisitLabel}</span>
</span>
</span>
</div>
{/if}

<div
class="flex flex-wrap items-center gap-x-2 gap-y-1 mt-3 text-xs text-muted-foreground"
class={cn(
'flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground',
lastVisitLabel && nextVisitLabel ? 'mt-1' : 'mt-3',
)}
>
<span>
<span class="font-semibold text-foreground tabular-nums">
{airlineCount}
</span>
airlines
</span>
<span aria-hidden="true">·</span>
<span>
<span class="font-semibold text-foreground tabular-nums">
{distinctRoutes}
<span class="inline-flex items-center gap-2">
<span aria-hidden="true">·</span>
<span>
<span class="font-semibold text-foreground tabular-nums">
{distinctRoutes}
</span>
routes
</span>
routes
</span>
{#if lastVisitLabel}
<span aria-hidden="true">·</span>
<span
>last visit <span class="text-foreground">{lastVisitLabel}</span></span
>
{#if Boolean(lastVisitLabel) !== Boolean(nextVisitLabel)}
<span class="inline-flex items-center gap-2">
<span aria-hidden="true">·</span>
<span>
{lastVisitLabel ? 'last' : 'next'} visit
<span class="text-foreground">
{lastVisitLabel ?? nextVisitLabel}
</span>
</span>
</span>
{/if}
</div>
</section>
10 changes: 1 addition & 9 deletions src/lib/components/airport-details/AirportTimeCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,10 @@
import { page } from '$app/state';
import { formatTime, getPreferences } from '$lib/utils/preferences';

let { tz }: { tz?: string | null } = $props();
let { tz, now }: { tz?: string | null; now: Date } = $props();

const prefs = $derived(getPreferences(page.data.user));

let now = $state(new Date());
$effect(() => {
const id = setInterval(() => {
now = new Date();
}, 30_000);
return () => clearInterval(id);
});

const resolvedTz = $derived(tz ?? 'UTC');

const localTime = $derived(formatTime(now, prefs, resolvedTz));
Expand Down
9 changes: 8 additions & 1 deletion src/lib/components/map-details/AirportDetailsBody.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,21 @@
onShowDepartures: (flightId?: number) => void;
onShowArrivals: (flightId?: number) => void;
} = $props();
let now = $state(new Date());
$effect(() => {
const id = setInterval(() => (now = new Date()), 30_000);
return () => clearInterval(id);
});
</script>

<AirportStatsCard
flights={relatedFlights}
airportId={airport.id}
airlineCount={airport.airlines.length}
{now}
/>
<AirportTimeCard tz={airport.tz} />
<AirportTimeCard tz={airport.tz} {now} />
<AirportWeatherCard icao={airport.icao} tz={airport.tz} lon={airport.lon} />
<AirportFlightsCard
flights={relatedFlights}
Expand Down
67 changes: 67 additions & 0 deletions src/lib/utils/data/airport-visits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';

import {
getAirportVisitSummary,
type AirportVisitFlight,
} from './airport-visits';

const baseFlight = (): AirportVisitFlight => ({
from: { id: 1, tz: 'America/Los_Angeles' },
to: { id: 2, tz: 'America/New_York' },
datePrecision: 'day',
duration: 14_400,
departure: null,
arrival: null,
departureScheduled: null,
arrivalScheduled: null,
takeoffScheduled: null,
takeoffActual: null,
landingScheduled: null,
landingActual: null,
raw: { date: '2024-06-14' },
});

describe('airport visit summary', () => {
it('uses day precision for an exact timestamp on a coarse flight date', () => {
const flight = {
...baseFlight(),
datePrecision: 'month',
arrivalScheduled: '2024-06-14T09:45:00.000Z',
} satisfies AirportVisitFlight;

expect(
getAirportVisitSummary([flight], 2, new Date('2024-07-01T00:00:00Z'))
.last,
).toContain('14');
});

it('derives an untimed arrival from departure and duration', () => {
const flight = {
...baseFlight(),
departureScheduled: '2024-06-15T06:00:00.000Z',
} satisfies AirportVisitFlight;

expect(
getAirportVisitSummary([flight], 2, new Date('2024-07-01T00:00:00Z'))
.last,
).toContain('15');
});

it('omits an arrival that cannot be placed on a calendar day', () => {
const flight = { ...baseFlight(), duration: null };

expect(
getAirportVisitSummary([flight], 2, new Date('2024-07-01T00:00:00Z')),
).toEqual({ last: null, next: null });
});

it('does not classify an imprecise range that contains now', () => {
expect(
getAirportVisitSummary(
[baseFlight()],
1,
new Date('2024-06-14T12:00:00Z'),
),
).toEqual({ last: null, next: null });
});
});
125 changes: 125 additions & 0 deletions src/lib/utils/data/airport-visits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { TZDate } from '@date-fns/tz';

import type { FlightDatePrecision } from '$lib/db/types';
import {
formatAsFlightDate,
getFlightDateRange,
parseLocalizeISO,
} from '$lib/utils/datetime';

type AirportRef = { id: number; tz: string };

export type AirportVisitFlight = {
from: AirportRef | null;
to: AirportRef | null;
datePrecision: FlightDatePrecision;
duration: number | null;
departure: TZDate | null;
arrival: TZDate | null;
departureScheduled: string | null;
arrivalScheduled: string | null;
takeoffScheduled: string | null;
takeoffActual: string | null;
landingScheduled: string | null;
landingActual: string | null;
raw: { date: string };
};

type VisitWindow = {
start: TZDate;
end: TZDate;
precision: FlightDatePrecision;
};

const parseTime = (value: string | null, tz: string) =>
value ? parseLocalizeISO(value, tz) : null;

const exactWindow = (date: TZDate): VisitWindow => ({
start: date,
end: date,
precision: 'day',
});

const departureTime = (flight: AirportVisitFlight) => {
const tz = flight.from?.tz ?? 'UTC';
return (
flight.departure ??
parseTime(flight.takeoffActual, tz) ??
parseTime(flight.departureScheduled, tz) ??
parseTime(flight.takeoffScheduled, tz)
);
};

const arrivalTime = (flight: AirportVisitFlight, departure: TZDate | null) => {
const tz = flight.to?.tz ?? 'UTC';
const recorded =
flight.arrival ??
parseTime(flight.landingActual, tz) ??
parseTime(flight.arrivalScheduled, tz) ??
parseTime(flight.landingScheduled, tz);

if (recorded || !departure || flight.duration === null) return recorded;
return new TZDate(departure.getTime() + flight.duration * 1_000, tz);
};

const visitWindow = (
flight: AirportVisitFlight,
direction: 'departure' | 'arrival',
): VisitWindow | null => {
const departure = departureTime(flight);
const exact =
direction === 'departure' ? departure : arrivalTime(flight, departure);
if (exact) return exactWindow(exact);
if (direction === 'arrival') return null;

const range = getFlightDateRange(
flight.raw.date,
flight.datePrecision,
flight.from?.tz ?? 'UTC',
);
if (!range.start || !range.end) return null;
return {
start: range.start,
end: range.end,
precision: flight.datePrecision,
};
};

type Visit = { label: string; time: number };

export const getAirportVisitSummary = (
flights: AirportVisitFlight[],
airportId: number,
now: Date,
) => {

Check failure on line 94 in src/lib/utils/data/airport-visits.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=johanohly_AirTrail&issues=AZ9Nya6gHupSTgeKwOsq&open=AZ9Nya6gHupSTgeKwOsq&pullRequest=640
let last: Visit | null = null;
let next: Visit | null = null;

for (const flight of flights) {
const windows = [
flight.from?.id === airportId ? visitWindow(flight, 'departure') : null,
flight.to?.id === airportId ? visitWindow(flight, 'arrival') : null,
];

for (const window of windows) {
if (!window) continue;
const label = formatAsFlightDate(
window.start,
window.precision,
false,
true,
);

if (window.end < now && (!last || window.end.getTime() > last.time)) {
last = { label, time: window.end.getTime() };
} else if (
window.start > now &&
(!next || window.start.getTime() < next.time)
) {
next = { label, time: window.start.getTime() };
}
}
}

return { last: last?.label ?? null, next: next?.label ?? null };
};
Loading