From 234e8709034f3535370ecbf5810d62e0e0203d31 Mon Sep 17 00:00:00 2001 From: Jon Larson Date: Thu, 2 Jul 2026 14:11:24 -0500 Subject: [PATCH 1/7] fix: correct airport last visit date and add next visit The airport stats card picked the flight with the max date across all related flights, so a future booked flight could show up as "last visit". Now last visit only considers flights whose actual arrival or departure time at that airport is in the past, and a new "next visit" line surfaces the nearest upcoming flight to that airport. --- .../airport-details/AirportStatsCard.svelte | 105 ++++++++++++++---- 1 file changed, 81 insertions(+), 24 deletions(-) diff --git a/src/lib/components/airport-details/AirportStatsCard.svelte b/src/lib/components/airport-details/AirportStatsCard.svelte index 15bd3a22..ffd7ad26 100644 --- a/src/lib/components/airport-details/AirportStatsCard.svelte +++ b/src/lib/components/airport-details/AirportStatsCard.svelte @@ -1,9 +1,10 @@
@@ -86,8 +122,33 @@ + {#if lastVisitLabel || nextVisitLabel} +
+ {#if lastVisitLabel} + + last visit {lastVisitLabel} + + {/if} + {#if nextVisitLabel} + + {#if lastVisitLabel} + + {/if} + + next visit {nextVisitLabel} + + + {/if} +
+ {/if} +
@@ -95,18 +156,14 @@ airlines - - - - {distinctRoutes} + + + + + {distinctRoutes} + + routes - routes - {#if lastVisitLabel} - - last visit {lastVisitLabel} - {/if}
From dde0816c0e48220e6dd79656c30d7eca0fd92574 Mon Sep 17 00:00:00 2001 From: Jon Larson Date: Thu, 2 Jul 2026 14:19:44 -0500 Subject: [PATCH 2/7] fix code formatting errors --- src/lib/components/airport-details/AirportStatsCard.svelte | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/lib/components/airport-details/AirportStatsCard.svelte b/src/lib/components/airport-details/AirportStatsCard.svelte index ffd7ad26..f0df2043 100644 --- a/src/lib/components/airport-details/AirportStatsCard.svelte +++ b/src/lib/components/airport-details/AirportStatsCard.svelte @@ -80,12 +80,7 @@ if (!flight) return null; const date = touchTime(flight); if (!date) return null; - return formatAsFlightDate( - date, - flight.datePrecision ?? 'day', - false, - true, - ); + return formatAsFlightDate(date, flight.datePrecision ?? 'day', false, true); }; const lastVisitLabel = $derived(formatVisit(lastVisit)); From 532775d6018d43a198ff804171a91740019751c7 Mon Sep 17 00:00:00 2001 From: Johan Ohly Date: Fri, 10 Jul 2026 22:11:32 +0200 Subject: [PATCH 3/7] fix: handle airport visit timing edge cases --- .../airport-details/AirportStatsCard.svelte | 109 +++++++++--------- .../airport-details/AirportTimeCard.svelte | 10 +- .../map-details/AirportDetailsBody.svelte | 9 +- 3 files changed, 64 insertions(+), 64 deletions(-) diff --git a/src/lib/components/airport-details/AirportStatsCard.svelte b/src/lib/components/airport-details/AirportStatsCard.svelte index f0df2043..7260c359 100644 --- a/src/lib/components/airport-details/AirportStatsCard.svelte +++ b/src/lib/components/airport-details/AirportStatsCard.svelte @@ -1,21 +1,71 @@
diff --git a/src/lib/components/airport-details/AirportTimeCard.svelte b/src/lib/components/airport-details/AirportTimeCard.svelte index 96234045..e68ac694 100644 --- a/src/lib/components/airport-details/AirportTimeCard.svelte +++ b/src/lib/components/airport-details/AirportTimeCard.svelte @@ -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)); diff --git a/src/lib/components/map-details/AirportDetailsBody.svelte b/src/lib/components/map-details/AirportDetailsBody.svelte index 72923555..238b8c0f 100644 --- a/src/lib/components/map-details/AirportDetailsBody.svelte +++ b/src/lib/components/map-details/AirportDetailsBody.svelte @@ -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); + }); - + Date: Fri, 10 Jul 2026 22:22:04 +0200 Subject: [PATCH 4/7] fix: keep single visit date inline --- .../airport-details/AirportStatsCard.svelte | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/lib/components/airport-details/AirportStatsCard.svelte b/src/lib/components/airport-details/AirportStatsCard.svelte index 7260c359..aeca1574 100644 --- a/src/lib/components/airport-details/AirportStatsCard.svelte +++ b/src/lib/components/airport-details/AirportStatsCard.svelte @@ -118,32 +118,26 @@ - {#if lastVisitLabel || nextVisitLabel} + {#if lastVisitLabel && nextVisitLabel}
- {#if lastVisitLabel} + + last visit {lastVisitLabel} + + + - last visit {lastVisitLabel} + next visit {nextVisitLabel} - {/if} - {#if nextVisitLabel} - - {#if lastVisitLabel} - - {/if} - - next visit {nextVisitLabel} - - - {/if} +
{/if}
@@ -161,5 +155,16 @@ routes + {#if Boolean(lastVisitLabel) !== Boolean(nextVisitLabel)} + + + + {lastVisitLabel ? 'last' : 'next'} visit + + {lastVisitLabel ?? nextVisitLabel} + + + + {/if}
From a1c6b499b2364eefabd48669f2cbe25b527dd520 Mon Sep 17 00:00:00 2001 From: Johan Ohly Date: Fri, 10 Jul 2026 22:31:56 +0200 Subject: [PATCH 5/7] fix: handle flight date range parsing for airport stats --- .../airport-details/AirportStatsCard.svelte | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/lib/components/airport-details/AirportStatsCard.svelte b/src/lib/components/airport-details/AirportStatsCard.svelte index aeca1574..467cbb84 100644 --- a/src/lib/components/airport-details/AirportStatsCard.svelte +++ b/src/lib/components/airport-details/AirportStatsCard.svelte @@ -3,7 +3,11 @@ import NumberFlow from '@number-flow/svelte'; import { cn, type FlightData } from '$lib/utils'; - import { formatAsFlightDate, parseLocalizeISO } from '$lib/utils/datetime'; + import { + formatAsFlightDate, + getFlightDateRange, + parseLocalizeISO, + } from '$lib/utils/datetime'; let { flights, @@ -44,9 +48,15 @@ const exact = actual ?? (scheduled ? parseLocalizeISO(scheduled, tz ?? 'UTC') : null); - const start = exact ?? flight.dateStart; - const end = exact ?? flight.dateEnd; - const date = exact ?? flight.date; + const range = exact + ? { start: exact, end: exact } + : getFlightDateRange( + flight.raw.date, + flight.datePrecision, + tz ?? 'UTC', + ); + const { start, end } = range; + const date = exact ?? start; if (!start || !end || !date) continue; const label = formatAsFlightDate( From 5ffc36a4c5d84f6958bc0c250e263f5a39874efd Mon Sep 17 00:00:00 2001 From: Johan Ohly Date: Fri, 10 Jul 2026 22:38:44 +0200 Subject: [PATCH 6/7] chore: address review feedback --- src/lib/components/airport-details/AirportStatsCard.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/airport-details/AirportStatsCard.svelte b/src/lib/components/airport-details/AirportStatsCard.svelte index 467cbb84..1b8d59c1 100644 --- a/src/lib/components/airport-details/AirportStatsCard.svelte +++ b/src/lib/components/airport-details/AirportStatsCard.svelte @@ -61,7 +61,7 @@ const label = formatAsFlightDate( date, - flight.datePrecision ?? 'day', + exact ? 'day' : (flight.datePrecision ?? 'day'), false, true, ); From 5f855abe3e5df39c4d7625c36a56cfbff95e9aeb Mon Sep 17 00:00:00 2001 From: Johan Ohly Date: Fri, 10 Jul 2026 22:47:28 +0200 Subject: [PATCH 7/7] chore: address review feedback --- .../airport-details/AirportStatsCard.svelte | 69 +--------- src/lib/utils/data/airport-visits.test.ts | 67 ++++++++++ src/lib/utils/data/airport-visits.ts | 125 ++++++++++++++++++ 3 files changed, 197 insertions(+), 64 deletions(-) create mode 100644 src/lib/utils/data/airport-visits.test.ts create mode 100644 src/lib/utils/data/airport-visits.ts diff --git a/src/lib/components/airport-details/AirportStatsCard.svelte b/src/lib/components/airport-details/AirportStatsCard.svelte index 1b8d59c1..0676cdbf 100644 --- a/src/lib/components/airport-details/AirportStatsCard.svelte +++ b/src/lib/components/airport-details/AirportStatsCard.svelte @@ -1,13 +1,9 @@
diff --git a/src/lib/utils/data/airport-visits.test.ts b/src/lib/utils/data/airport-visits.test.ts new file mode 100644 index 00000000..05c87eae --- /dev/null +++ b/src/lib/utils/data/airport-visits.test.ts @@ -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 }); + }); +}); diff --git a/src/lib/utils/data/airport-visits.ts b/src/lib/utils/data/airport-visits.ts new file mode 100644 index 00000000..311a68d6 --- /dev/null +++ b/src/lib/utils/data/airport-visits.ts @@ -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, +) => { + 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 }; +};