Skip to content

Commit

Permalink
add flight search table
Browse files Browse the repository at this point in the history
  • Loading branch information
its-felix committed Sep 8, 2024
1 parent d754366 commit c03efce
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 25 deletions.
2 changes: 1 addition & 1 deletion go/api/web/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func NewSearchEndpoint(s3c adapt.S3Lister, bucket string) echo.HandlerFunc {
resp, err := s3c.ListObjectsV2(c.Request().Context(), &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String(prefix),
MaxKeys: aws.Int32(500),
MaxKeys: aws.Int32(100),
})

if err != nil {
Expand Down
4 changes: 3 additions & 1 deletion ui/src/components/header/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ function TopNavigationSearch() {
const navigate = useNavigate();

const [query, setQuery] = useState('');
const results = useSearch(useDebounce(query, 250));
const [searchEnabled, setSearchEnabled] = useState(false);
const results = useSearch(useDebounce(query, 250), searchEnabled);

const options = useMemo<ReadonlyArray<AutosuggestProps.Option>>(() => {
const opts: Array<SelectProps.Option> = [];
Expand Down Expand Up @@ -151,6 +152,7 @@ function TopNavigationSearch() {
navigate(`/flight/${encodeURIComponent(e.detail.value.toUpperCase())}`);
}
}}
onFocus={() => setSearchEnabled(true)}
/>
)
}
2 changes: 1 addition & 1 deletion ui/src/components/util/context/app-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export function catchNotify(notifications: AppControls['notification'] | Dispatc
};
}

function ErrorNotificationContent({ errText, error: e }: { errText?: string, error: unknown }) {
export function ErrorNotificationContent({ errText, error: e }: { errText?: string, error: unknown }) {
let errMessage: string | undefined;
let errDetails: React.ReactNode;

Expand Down
21 changes: 19 additions & 2 deletions ui/src/components/util/state/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,28 @@ export function useFlight(flightNumber: string, airport: string, date: DateTime<
});
}

export function useSearch(query: string) {
export function useFlightSchedule(flightNumber: string) {
const { apiClient } = useHttpClient();
return useQuery({
queryKey: ['search', query],
queryKey: ['flight_schedule', flightNumber],
queryFn: async () => {
const { body } = expectSuccess(await apiClient.getFlightSchedule(flightNumber));
return body;
},
retry: 3,
staleTime: 1000 * 60 * 15,
});
}

export function useSearch(query: string, enabled: boolean) {
const { apiClient } = useHttpClient();
return useQuery({
queryKey: ['search', query, enabled],
queryFn: async () => {
if (!enabled) {
return [];
}

const { body } = expectSuccess(await apiClient.search(query));
return body;
},
Expand Down
27 changes: 27 additions & 0 deletions ui/src/lib/api/api.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,31 @@ export interface Aircraft {
export interface ConnectionSearchShare {
htmlUrl: string;
imageUrl: string;
}

export interface FlightScheduleVariantData {
operatedAs: string;
departureTime: string;
departureAirport: string;
departureUTCOffset: number;
durationSeconds: number;
arrivalAirport: string;
arrivalUTCOffset: number;
serviceType: string;
aircraftOwner: string;
aircraftType: string;
aircraftConfigurationVersion: string;
codeShares: ReadonlyArray<string>;
}

export interface FlightScheduleVariant {
ranges: ReadonlyArray<[string, string]>;
data: FlightScheduleVariantData;
}

export interface FlightSchedule {
airline: string;
flightNumber: number;
suffix: string;
variants: ReadonlyArray<FlightScheduleVariant>;
}
6 changes: 5 additions & 1 deletion ui/src/lib/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
AuthInfo,
ConnectionSearchShare,
ConnectionsSearchRequest,
ConnectionsSearchResponseWithSearch, ConnectionsSearchResponse, Flight
ConnectionsSearchResponseWithSearch, ConnectionsSearchResponse, Flight, FlightSchedule
} from './api.model';
import { DateTime } from 'luxon';
import { ConcurrencyLimit } from './concurrency-limit';
Expand Down Expand Up @@ -81,6 +81,10 @@ export class ApiClient {
));
}

getFlightSchedule(flightNumber: string): Promise<ApiResponse<FlightSchedule>> {
return transform(this.httpClient.fetch(`/data/flight/${encodeURIComponent(flightNumber)}`));
}

getConnections(req: ConnectionsSearchRequest): Promise<ApiResponse<ConnectionsSearchResponse>> {
return transform(this.httpClient.fetch(
'/api/connections/json',
Expand Down
228 changes: 212 additions & 16 deletions ui/src/pages/flight.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,229 @@
import React, { useEffect, useState } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { ContentLayout, Header } from '@cloudscape-design/components';
import {
Alert, Box,
ColumnLayout,
ContentLayout,
ExpandableSection,
Header, Pagination, Popover,
Spinner,
Table
} from '@cloudscape-design/components';
import { CodeView } from '@cloudscape-design/code-view';
import jsonHighlight from '@cloudscape-design/code-view/highlight/json';
import { useHttpClient } from '../components/util/context/http-client';
import { JsonType } from '../lib/api/api.model';
import { catchNotify, useAppControls } from '../components/util/context/app-controls';
import { expectSuccess } from '../lib/api/api';
import { useAircraft, useAirports, useFlightSchedule } from '../components/util/state/data';
import { ErrorNotificationContent } from '../components/util/context/app-controls';
import { Aircraft, Airport, FlightSchedule, FlightScheduleVariantData } from '../lib/api/api.model';
import { DateTime, Duration, FixedOffsetZone } from 'luxon';
import { useCollection } from '@cloudscape-design/collection-hooks';
import { RouterLink } from '../components/common/router-link';

export function FlightView() {
const { id } = useParams();
if (!id) {
throw new Error();
}

const { notification } = useAppControls();
const { apiClient } = useHttpClient();
const [result, setResult] = useState<JsonType>({});
const flightScheduleResult = useFlightSchedule(id);
let content: React.ReactNode;

useEffect(() => {
(async () => {
setResult(expectSuccess(await apiClient.raw(`/data/flight/${encodeURIComponent(id)}`)).body);
})()
.catch(catchNotify(notification));
}, [id]);
if (flightScheduleResult.data) {
content = <FlightScheduleContent flightSchedule={flightScheduleResult.data} />;
} else if (flightScheduleResult.status === 'pending') {
content = <Spinner size={'large'} />;
} else {
let error = flightScheduleResult.error;
if (!error) {
error = new Error(flightScheduleResult.status);
}

content = (
<Alert type={'error'}>
<ErrorNotificationContent error={error} />;
</Alert>
);
}

return (
<ContentLayout header={<Header variant={'h1'}>Flight Detail</Header>}>
<CodeView content={JSON.stringify(result, null, 2)} highlight={jsonHighlight} lineNumbers={true} />
{content}
</ContentLayout>
)
}

type TableItem = [DateTime<true>, FlightScheduleVariantData];
function FlightScheduleContent({ flightSchedule }: { flightSchedule: FlightSchedule }) {
const airportLookup = useAirportLookup();
const aircraftLookup = useAircraftLookup();
const flightNumber = useMemo(() => `${flightSchedule.airline}${flightSchedule.flightNumber}${flightSchedule.suffix}`, [flightSchedule]);
const rawItems = useMemo(() => flattenFlightSchedule(flightSchedule), [flightSchedule]);
const { items, collectionProps, paginationProps, allPageItems } = useCollection(rawItems, {
sorting: {},
pagination: { pageSize: 25 },
});

return (
<ColumnLayout columns={1}>
<Table
items={items}
{...collectionProps}
header={<Header counter={`(${allPageItems.length})`}>Flights</Header>}
pagination={<Pagination {...paginationProps} />}
filter={<Box variant={'small'}>Filter coming soon</Box>}
variant={'container'}
columnDefinitions={[
{
id: 'departure_time',
header: 'Departure Time',
cell: ([departureTime]) => <TimeCell value={departureTime} />,
sortingComparator: useCallback((a: TableItem, b: TableItem) => a[0].toMillis() - b[0].toMillis(), []),
},
{
id: 'operated_as',
header: 'Operated As',
cell: ([,data]) => {
if (data.operatedAs !== flightNumber) {
return <FlightLink flightNumber={data.operatedAs} />;
}

return data.operatedAs;
},
},
{
id: 'departure_airport',
header: 'Departure Airport',
cell: ([,data]) => <AirportCell code={data.departureAirport} lookup={airportLookup} />,
sortingComparator: useCallback((a: TableItem, b: TableItem) => a[1].departureAirport.localeCompare(b[1].departureAirport), []),
},
{
id: 'arrival_airport',
header: 'Arrival Airport',
cell: ([,data]) => <AirportCell code={data.arrivalAirport} lookup={airportLookup} />,
sortingComparator: useCallback((a: TableItem, b: TableItem) => a[1].arrivalAirport.localeCompare(b[1].arrivalAirport), []),
},
{
id: 'arrival_time',
header: 'Arrival Time',
cell: ([departureTime, data]) => {
const arrivalTime = departureTime.plus(Duration.fromMillis(data.durationSeconds * 1000)).setZone(FixedOffsetZone.instance(data.arrivalUTCOffset / 60));
if (!arrivalTime.isValid) {
return 'UNKNOWN';
}

return <TimeCell value={arrivalTime} />;
},
},
{
id: 'aircraft',
header: 'Aircraft',
cell: ([,data]) => {
const aircraft = aircraftLookup.get(data.aircraftType);
if (aircraft) {
return <Popover content={<CodeView content={JSON.stringify(aircraft, null, 2)} highlight={jsonHighlight} />}>{aircraft.name}</Popover>;
}

return data.aircraftType;
},
sortingComparator: useCallback((a: TableItem, b: TableItem) => a[1].aircraftType.localeCompare(b[1].aircraftType), []),
},
{
id: 'code_shares',
header: 'Codeshares',
cell: ([,data]) => (
<ColumnLayout columns={data.codeShares.length} variant={'text-grid'}>
{...data.codeShares.map((v) => <FlightLink flightNumber={v} />)}
</ColumnLayout>
),
}
]}
/>
<ExpandableSection headerText={'Raw Data'}>
<CodeView content={JSON.stringify(flightSchedule, null, 2)} highlight={jsonHighlight} lineNumbers={true} />
</ExpandableSection>
</ColumnLayout>
);
}

function FlightLink({ flightNumber }: { flightNumber: string }) {
return <RouterLink to={`/flight/${encodeURIComponent(flightNumber)}`}>{flightNumber}</RouterLink>;
}

function AirportCell({ code, lookup }: { code: string, lookup: Map<string, Airport> }) {
const airport = useMemo(() => lookup.get(code), [code, lookup]);
if (airport) {
return <Popover content={airport.name}>{code}</Popover>;
}

return code;
}

function TimeCell({ value }: { value: DateTime<true> }) {
const date = value.toFormat('yyyy-MM-dd');
const time = value.toFormat('HH:mm:ss (ZZ)');

return (
<ColumnLayout columns={2} variant={'text-grid'}>
{date}
{time}
</ColumnLayout>
)
}

function flattenFlightSchedule(flightSchedule: FlightSchedule): ReadonlyArray<TableItem> {
const result: Array<[DateTime<true>, FlightScheduleVariantData]> = [];
for (const variant of flightSchedule.variants) {
let departureUTCOffsetStr = Duration.fromMillis(Math.abs(variant.data.departureUTCOffset * 1000)).toFormat('hh:mm');
if (variant.data.departureUTCOffset >= 0) {
departureUTCOffsetStr = '+' + departureUTCOffsetStr;
} else {
departureUTCOffsetStr = '-' + departureUTCOffsetStr;
}

for (const range of variant.ranges) {
const [startISODate, endISODate] = range;
const start = DateTime.fromISO(`${startISODate}T${variant.data.departureTime}.000${departureUTCOffsetStr}`, { setZone: true });
const end = DateTime.fromISO(`${endISODate}T${variant.data.departureTime}.000${departureUTCOffsetStr}`, { setZone: true });

if (start.isValid && end.isValid) {
let curr = start;
while (curr <= end) {
result.push([curr, variant.data]);
curr = curr.plus(Duration.fromObject({ days: 1 }));
}
}
}
}

result.sort((a, b) => a[0].toMillis() - b[0].toMillis());
return result;
}

function useAirportLookup() {
const airports = useAirports().data;
return useMemo(() => {
const map = new Map<string, Airport>();
for (const airport of airports.airports) {
map.set(airport.code, airport);
}

for (const metroArea of airports.metropolitanAreas) {
for (const airport of metroArea.airports) {
map.set(airport.code, airport);
}
}

return map;
}, [airports]);
}

function useAircraftLookup() {
const aircraft = useAircraft().data;
return useMemo(() => {
const map = new Map<string, Aircraft>();
for (const v of aircraft) {
map.set(v.code, v);
}

return map;
}, [aircraft]);
}
4 changes: 1 addition & 3 deletions ui/src/pages/tools/mm-quick-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,7 @@ function AvailabilityTable({ items: rawItems, onClear }: { items: ReadonlyArray<
{
id: 'departure_date',
header: 'Departure Date',
cell: (v) => {
return v.entry.departureDate
},
cell: (v) => v.entry.departureDate,
sortingComparator: sortByDepartureDate,
},
{
Expand Down

0 comments on commit c03efce

Please sign in to comment.