diff --git a/api/migrations/10232024_initial.sql b/api/migrations/10232024_initial.sql index 49cc728..147664f 100644 --- a/api/migrations/10232024_initial.sql +++ b/api/migrations/10232024_initial.sql @@ -14,7 +14,8 @@ CREATE TABLE IF NOT EXISTS airports ( latitude REAL NOT NULL, has_tower BOOLEAN DEFAULT false, has_beacon BOOLEAN DEFAULT false, - public BOOLEAN DEFAULT false + public BOOLEAN DEFAULT false, + metar_observation_time TIMESTAMPTZ ); CREATE INDEX ON airports (iata); @@ -25,6 +26,7 @@ CREATE INDEX ON airports (iso_country); CREATE INDEX ON airports (iso_region); CREATE INDEX ON airports (municipality); CREATE INDEX ON airports (longitude, latitude); +CREATE INDEX ON airports (metar_observation_time); CREATE TABLE IF NOT EXISTS runways ( id UUID PRIMARY KEY NOT NULL, diff --git a/api/src/airports/model/airport.rs b/api/src/airports/model/airport.rs index 1de172e..14f64e4 100644 --- a/api/src/airports/model/airport.rs +++ b/api/src/airports/model/airport.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::str::FromStr; +use chrono::{DateTime, Utc}; use futures_util::try_join; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -121,6 +122,7 @@ struct AirportRow { pub has_tower: Option, pub has_beacon: Option, pub public: bool, + pub metar_observation_time: Option>, } #[derive(Debug, Deserialize)] @@ -141,6 +143,7 @@ pub struct UpdateAirport { pub runways: Option>, pub frequencies: Option>, pub public: Option, + pub latest_metar_observation: Option>, } impl Into for Airport { @@ -160,6 +163,10 @@ impl Into for Airport { has_tower: self.has_tower, has_beacon: self.has_beacon, public: self.public, + metar_observation_time: match self.latest_metar { + Some(m) => Some(m.observation_time), + None => None, + }, } } } @@ -303,7 +310,8 @@ impl Airport { Self::push_condition_bounds(&mut builder, &mut has_where, &query.bounds)?; // Order by AircraftCategory - builder.push(" ORDER BY CASE category "); + builder.push(" ORDER BY (metar_observation_time IS NULL), "); + builder.push(" CASE category "); builder.push(" WHEN 'large_airport' THEN 1 "); builder.push(" WHEN 'medium_airport' THEN 2 "); builder.push(" WHEN 'small_airport' THEN 3 "); @@ -516,7 +524,20 @@ impl Airport { } // TODO - pub async fn update(_icao: &str, _airport: &UpdateAirport) -> ApiResult<()> { + pub async fn update(icao: &str, airport: &UpdateAirport) -> ApiResult<()> { + let pool = db::pool(); + + let mut query_builder: QueryBuilder = + QueryBuilder::new(format!("UPDATE {} SET ", TABLE_NAME)); + if let Some(latest_metar_observation) = airport.latest_metar_observation { + query_builder.push("metar_observation_time = "); + query_builder.push_bind(latest_metar_observation); + } + + query_builder.push(" WHERE icao = ").push_bind(icao); + let query = query_builder.build(); + query.execute(pool).await?; + Ok(()) } diff --git a/api/src/metars/model.rs b/api/src/metars/model.rs index a702e5a..faee048 100644 --- a/api/src/metars/model.rs +++ b/api/src/metars/model.rs @@ -8,6 +8,7 @@ use std::str::FromStr; use redis::{AsyncCommands, RedisResult}; use reqwest::Client; use serde::{Deserialize, Serialize}; +use crate::airports::{Airport, UpdateAirport}; use crate::db::redis_async_connection; const TABLE_NAME: &str = "metars"; @@ -63,7 +64,7 @@ pub enum ReportModifier { #[serde(rename = "AUTO")] Auto, #[serde(rename = "COR")] - Corrected + Corrected, } impl FromStr for ReportModifier { @@ -72,7 +73,7 @@ impl FromStr for ReportModifier { match s { "AUTO" => Ok(ReportModifier::Auto), "COR" => Ok(ReportModifier::Corrected), - _ => Err(Error::new(400, format!("Invalid report modifier '{}'", s))) + _ => Err(Error::new(400, format!("Invalid report modifier '{}'", s))), } } } @@ -122,7 +123,10 @@ impl FromStr for AutomatedStationType { match s { "AO1" => Ok(AutomatedStationType::WithoutPrecipitationDiscriminator), "AO2" => Ok(AutomatedStationType::WithPrecipitationDiscriminator), - _ => Err(Error::new(400, format!("Invalid automated station type '{}'", s))) + _ => Err(Error::new( + 400, + format!("Invalid automated station type '{}'", s), + )), } } } @@ -522,9 +526,7 @@ impl Metar { format!( "P{}", visibility_whole - + (visibility_left[1..visibility_left.len()] - .parse::()? - / visibility_right) + + (visibility_left[1..visibility_left.len()].parse::()? / visibility_right) ) } else { format!( @@ -895,10 +897,7 @@ impl Metar { ) -> Vec { let mut missing_metar_icaos: Vec = vec![]; let current_time = chrono::Local::now().naive_local().and_utc().timestamp(); - let db_metars_set: HashSet<&str> = db_metars - .iter() - .map(|icao| icao.icao.as_str()) - .collect(); + let db_metars_set: HashSet<&str> = db_metars.iter().map(|icao| icao.icao.as_str()).collect(); let station_icaos_set: HashSet<&str> = station_icaos.iter().map(|s| s.as_str()).collect(); for difference in db_metars_set.symmetric_difference(&station_icaos_set) { missing_metar_icaos.push(difference.to_string()); @@ -986,17 +985,63 @@ impl Metar { let pool = db::pool(); let metar_rows: Vec = sqlx::query_as::<_, MetarRow>(&format!( r#" - SELECT DISTINCT ON (icao) * FROM {} WHERE icao = ANY($1) ORDER BY icao, observation_time DESC - "#, + SELECT DISTINCT ON (icao) * FROM {} + WHERE icao = ANY($1) + AND observation_time >= NOW() - INTERVAL '50 minutes' + ORDER BY icao, observation_time DESC + "#, TABLE_NAME )) .bind(icao_list) .fetch_all(pool) .await?; - metars = metar_rows - .into_iter() - .filter_map(|metar_db| Metar::from_db(metar_db).ok()) - .collect(); + for metar_row in metar_rows { + let metar = match Metar::from_db(metar_row) { + Ok(m) => m, + Err(err) => { + log::error!("{}", err); + continue; + } + }; + + let icao = metar.icao.clone(); + let observation_time = metar.observation_time.clone(); + tokio::spawn(async move { + match Airport::update( + &icao, + &UpdateAirport { + icao: None, + iata: None, + local: None, + name: None, + category: None, + iso_country: None, + iso_region: None, + municipality: None, + elevation_ft: None, + longitude: None, + latitude: None, + has_tower: None, + has_beacon: None, + runways: None, + frequencies: None, + public: None, + latest_metar_observation: Some(observation_time), + }, + ) + .await + { + Ok(_) => {} + Err(err) => log::error!( + "Unable to update airport {} with the latest observation time: {}", + icao, + err + ), + }; + }); + + metars.push(metar); + } } let mut conn = redis_async_connection().await?; @@ -1011,17 +1056,19 @@ impl Metar { let result: RedisResult> = conn.get(icao).await; match result { Ok(Some(value)) => { - if value { + if !value { updated_missing_icao_list.push(icao); } } - Ok(None) => { - updated_missing_icao_list.push(icao); + Ok(None) => updated_missing_icao_list.push(icao), + Err(err) => { + log::error!("{}", err); + return Err(err.into()); } - Err(err) => return Err(err.into()), } } } + dbg!(&updated_missing_icao_list); if !updated_missing_icao_list.is_empty() { log::trace!( "Retrieving missing METAR data for {:?}", diff --git a/ui/package-lock.json b/ui/package-lock.json index b303636..7280f44 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "aviation-ui", - "version": "0.1.2", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aviation-ui", - "version": "0.1.2", + "version": "0.1.0", "dependencies": { "@mantine/core": "^7.17.2", "@mantine/form": "^7.17.2", diff --git a/ui/src/components/AirportDrawer.tsx b/ui/src/components/AirportDrawer.tsx index f14038f..91d5dbd 100644 --- a/ui/src/components/AirportDrawer.tsx +++ b/ui/src/components/AirportDrawer.tsx @@ -1,5 +1,8 @@ -import { Divider, Drawer, Group } from '@mantine/core'; +import { Box, Divider, Drawer, Group, Text } from '@mantine/core'; import { Airport, AirportCategory } from '@lib/airport.types.ts'; +import { getMarkerColor, Metar } from '@lib/metar.types.ts'; +import { useEffect, useState } from 'react'; +import { getMetars } from '@lib/metar.ts'; export default function AirportDrawer({ airport, @@ -8,9 +11,24 @@ export default function AirportDrawer({ airport: Airport | null; setAirport: (airport: Airport | null) => void; }) { + const [metar, setMetar] = useState(undefined); + + useEffect(() => { + if (airport != null) { + getMetars({ icaos: [airport.icao] }).then((m) => { + if (m.length > 0) { + setMetar(m[0]); + } + }); + } + }, [airport]); + if (!airport) { return null; } + + const metarColor = getMarkerColor(metar?.flight_category || 'UNKN'); + return ( - -
ICAO: {airport.icao}
-
Category: {airportCategoryToText(airport.category)}
-
- Country / Region: {airport.iso_country}, {airport.iso_region} -
-
Municipality: {airport.municipality || 'N/A'}
-
Local Code: {airport.local || 'N/A'}
-
Elevation: {airport.elevation_ft}
-
- Coordinates: {airport.latitude.toFixed(4)}, {airport.longitude.toFixed(4)} -
-
Control Tower: {airport.has_tower ? 'Yes' : 'No'}
-
Beacon: {airport.has_beacon ? 'Yes' : 'No'}
- {airport.latest_metar && airport.latest_metar.flight_category && ( - <> - -
Flight Category: {airport.latest_metar.flight_category}
- + + {metar && metar.flight_category && ( + + {metar.flight_category} + {metar.observation_time ? new Date(metar.observation_time).toLocaleString() : 'N/A'} + )} -
+ +
ICAO: {airport.icao}
+
Category: {airportCategoryToText(airport.category)}
+
+ Country / Region: {airport.iso_country}, {airport.iso_region} +
+
Municipality: {airport.municipality || 'N/A'}
+
Local Code: {airport.local || 'N/A'}
+
Elevation: {airport.elevation_ft}
+
+ Coordinates: {airport.latitude.toFixed(4)}, {airport.longitude.toFixed(4)} +
+
Control Tower: {airport.has_tower ? 'Yes' : 'No'}
+
Beacon: {airport.has_beacon ? 'Yes' : 'No'}
+ {metar && metar.flight_category && ( + <> + +
Flight Category: {metar.flight_category}
+ + )} +
+
); } diff --git a/ui/src/components/AirportMarker.tsx b/ui/src/components/AirportMarker.tsx index 09fc75c..5047407 100644 --- a/ui/src/components/AirportMarker.tsx +++ b/ui/src/components/AirportMarker.tsx @@ -2,6 +2,7 @@ import { Airport, AirportCategory } from '@lib/airport.types.ts'; import { Marker, Popup } from 'react-leaflet'; import L from 'leaflet'; import { useRef } from 'react'; +import { getMarkerColor } from '@lib/metar.types.ts'; export default function AirportMarker({ index, @@ -34,21 +35,6 @@ export default function AirportMarker({ ); } -function getMarkerInfo(flightCategory: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN'): [string, number] { - switch (flightCategory) { - case 'IFR': - return ['#ff0100', 5]; - case 'LIFR': - return ['#7f007f', 6]; - case 'MVFR': - return ['#00f', 7]; - case 'VFR': - return ['#018000', 8]; - case 'UNKN': - return ['#696969', 4]; - } -} - function createCustomIcon(airport: Airport): L.DivIcon { if (airport.category === AirportCategory.HELIPORT) { return L.divIcon({ @@ -73,12 +59,12 @@ function createCustomIcon(airport: Airport): L.DivIcon { } else { // Default to a filled circle. const flightCategory = airport.latest_metar?.flight_category || 'UNKN'; - const info = getMarkerInfo(flightCategory); + const color = getMarkerColor(flightCategory); if (flightCategory == 'UNKN') { return L.divIcon({ html: `
{ + const response = await getRequest('metars', { + icaos: icaos, + force: force + }); + return response?.json() || {}; +} diff --git a/ui/src/lib/metar.types.ts b/ui/src/lib/metar.types.ts index 848f91f..3433f69 100644 --- a/ui/src/lib/metar.types.ts +++ b/ui/src/lib/metar.types.ts @@ -3,12 +3,24 @@ export interface SkyCondition { cloud_base_ft_agl: number; } -export interface QualityControlFlags { - auto: boolean; - auto_station_without_precipitation: boolean; - auto_station_with_precipication: boolean; - maintenance_indicator_on: boolean; - corrected: boolean; +export interface PeakWind { + degrees: number; + speed: number; + hour?: number; + minute?: number; +} + +export interface Remarks { + peak_wind?: PeakWind; + auto_station_type?: string; + maintenance_indicator_on?: boolean; + rvr_missing?: boolean; + precipitation_identifier_information_not_available?: boolean; + precipitation_information_not_available?: boolean; + freezing_rain_information_not_available?: boolean; + thunderstorm_information_not_available?: boolean; + visibility_at_secondary_location_not_available?: boolean; + sky_condition_at_secondary_location_not_available?: boolean; } export interface RunwayVisualRange { @@ -19,25 +31,44 @@ export interface RunwayVisualRange { } export interface Metar { + icao: string; raw_text: string; - station_id: string; observation_time: string; - temp_c: number; - dewpoint_c: number; - wind_dir_degrees: string; - wind_speed_kt: number; - wind_gust_kt: number; - variable_wind_dir_degrees: string; - visibility_statute_mi: string; + flight_category: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN'; + report_modifier?: string; + becoming_change?: boolean; + no_significant_change?: boolean; + temporary_change?: boolean; + temp_c?: number; + dew_point_c?: number; + estimated_humidity?: number; + wind_dir_degrees?: string; + wind_speed_kt?: number; + wind_gust_kt?: number; + variable_wind_dir_degrees?: string; + visibility_statute_mi?: string; runway_visual_range: RunwayVisualRange[]; - altim_in_hg: number; - sea_level_pressure_mb: number; - quality_control_flags: QualityControlFlags; + altimeter_in_hg?: number; + sea_level_pressure_mb?: number; + remarks: Remarks; weather_phenomena: string[]; sky_condition: SkyCondition[]; - flight_category: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN'; - three_hr_pressure_tendency_mb: number; - max_t_c: number; - min_t_c: number; - precip_in: number; + max_temp_c?: number; + min_temp_c?: number; + density_altutude?: number; +} + +export function getMarkerColor(flightCategory: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN'): string { + switch (flightCategory) { + case 'IFR': + return '#ff0100'; + case 'LIFR': + return '#7f007f'; + case 'MVFR': + return '#00f'; + case 'VFR': + return '#018000'; + case 'UNKN': + return '#696969'; + } }