Lots of improvements
This commit is contained in:
parent
205302dbb8
commit
eb02b5832b
39 changed files with 699 additions and 271 deletions
|
|
@ -59,6 +59,8 @@ pub struct HexagonStatsResponse {
|
|||
pub enum_features: Vec<EnumFeatureStats>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub price_history: Vec<PricePoint>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub central_postcode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -136,6 +138,31 @@ pub async fn get_hexagon_stats(
|
|||
|
||||
let total_count = matching_rows.len();
|
||||
|
||||
// Find the postcode of the property closest to the hexagon center
|
||||
let central_postcode = if !matching_rows.is_empty() {
|
||||
let center: h3o::LatLng = cell.into();
|
||||
let center_lat = center.lat() as f32;
|
||||
let center_lon = center.lng() as f32;
|
||||
let closest_row = matching_rows
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by(|&a, &b| {
|
||||
let da_lat = state.data.lat[a] - center_lat;
|
||||
let da_lon = state.data.lon[a] - center_lon;
|
||||
let db_lat = state.data.lat[b] - center_lat;
|
||||
let db_lon = state.data.lon[b] - center_lon;
|
||||
let dist_a = da_lat * da_lat + da_lon * da_lon;
|
||||
let dist_b = db_lat * db_lat + db_lon * db_lon;
|
||||
dist_a
|
||||
.partial_cmp(&dist_b)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.expect("matching_rows is non-empty");
|
||||
Some(state.data.postcode(closest_row).to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let price_history = stats::extract_price_history(
|
||||
&matching_rows,
|
||||
feature_data,
|
||||
|
|
@ -170,6 +197,7 @@ pub async fn get_hexagon_stats(
|
|||
numeric_features,
|
||||
enum_features: enum_features_out,
|
||||
price_history,
|
||||
central_postcode,
|
||||
})
|
||||
})
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -43,12 +43,13 @@ pub struct HexagonParams {
|
|||
struct TravelEntry {
|
||||
mode: String,
|
||||
slug: String,
|
||||
use_best: bool,
|
||||
filter_min: Option<f32>,
|
||||
filter_max: Option<f32>,
|
||||
}
|
||||
|
||||
/// Parse `travel` param into a list of travel entries.
|
||||
/// Format: `mode:slug` or `mode:slug:min:max`
|
||||
/// Format: `mode:slug` or `mode:slug:best` or `mode:slug:min:max` or `mode:slug:best:min:max`
|
||||
fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
|
||||
let mut entries = Vec::new();
|
||||
let mut seen_keys = Vec::new();
|
||||
|
|
@ -63,12 +64,15 @@ fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
|
|||
let mode = parts[0].trim().to_string();
|
||||
let slug = parts[1].trim().to_string();
|
||||
|
||||
let (filter_min, filter_max) = if parts.len() >= 4 {
|
||||
let min: f32 = parts[2]
|
||||
let use_best = parts.len() >= 3 && parts[2].trim() == "best";
|
||||
let filter_offset = if use_best { 1 } else { 0 };
|
||||
|
||||
let (filter_min, filter_max) = if parts.len() >= 4 + filter_offset {
|
||||
let min: f32 = parts[2 + filter_offset]
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid travel filter min in '{}'", segment))?;
|
||||
let max: f32 = parts[3]
|
||||
let max: f32 = parts[3 + filter_offset]
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid travel filter max in '{}'", segment))?;
|
||||
|
|
@ -85,6 +89,7 @@ fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
|
|||
entries.push(TravelEntry {
|
||||
mode,
|
||||
slug,
|
||||
use_best,
|
||||
filter_min,
|
||||
filter_max,
|
||||
});
|
||||
|
|
@ -286,7 +291,14 @@ pub async fn get_hexagons(
|
|||
let postcode = pc_interner.resolve(&pc_keys[row]);
|
||||
travel_minutes.reserve(travel_entries.len());
|
||||
for (ti, entry) in travel_entries.iter().enumerate() {
|
||||
let minutes = travel_data[ti].get(postcode).copied();
|
||||
let row_data = travel_data[ti].get(postcode);
|
||||
let minutes = row_data.map(|r| {
|
||||
if entry.use_best {
|
||||
r.best_minutes.unwrap_or(r.minutes)
|
||||
} else {
|
||||
r.minutes
|
||||
}
|
||||
});
|
||||
travel_minutes.push(minutes);
|
||||
if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) {
|
||||
match minutes {
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@ use crate::state::AppState;
|
|||
|
||||
/// Dedicated HTTP client for proxying — does not follow redirects so 3xx
|
||||
/// responses are passed through to the browser (needed for OAuth flows).
|
||||
/// No overall timeout because SSE (Server-Sent Events) connections used by
|
||||
/// PocketBase realtime/OAuth2 are long-lived streams.
|
||||
static PROXY_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||
reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(30))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("Failed to build proxy HTTP client")
|
||||
|
|
@ -97,16 +98,12 @@ pub async fn proxy_to_pocketbase(state: Arc<AppState>, req: Request) -> impl Int
|
|||
}
|
||||
}
|
||||
|
||||
match upstream.bytes().await {
|
||||
Ok(bytes) => response.body(Body::from(bytes)).unwrap(),
|
||||
Err(err) => {
|
||||
warn!("Failed to read upstream response: {err}");
|
||||
Response::builder()
|
||||
.status(StatusCode::BAD_GATEWAY)
|
||||
.body(Body::from("Failed to read upstream response"))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
// Stream the response body instead of buffering it entirely.
|
||||
// This is critical for SSE (Server-Sent Events) used by PocketBase's
|
||||
// realtime system and OAuth2 flow — buffering would hang forever
|
||||
// since SSE responses never complete.
|
||||
let body = Body::from_stream(upstream.bytes_stream());
|
||||
response.body(body).unwrap()
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("PocketBase proxy error: {err}");
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ pub async fn get_postcode_stats(
|
|||
numeric_features,
|
||||
enum_features: enum_features_out,
|
||||
price_history,
|
||||
central_postcode: None,
|
||||
})
|
||||
})
|
||||
.await
|
||||
|
|
|
|||
83
server-rs/src/routes/rightmove_typeahead.rs
Normal file
83
server-rs/src/routes/rightmove_typeahead.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Query;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
const TYPEAHEAD_URL: &str = "https://los.rightmove.co.uk/typeahead";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TypeaheadParams {
|
||||
pub postcode: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TypeaheadResponse {
|
||||
pub location_identifier: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RightmoveMatch {
|
||||
#[serde(rename = "type")]
|
||||
match_type: String,
|
||||
#[serde(rename = "displayName")]
|
||||
display_name: String,
|
||||
id: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RightmoveTypeaheadResponse {
|
||||
matches: Vec<RightmoveMatch>,
|
||||
}
|
||||
|
||||
pub async fn get_rightmove_typeahead(
|
||||
state: Arc<AppState>,
|
||||
Query(params): Query<TypeaheadParams>,
|
||||
) -> Result<Json<TypeaheadResponse>, axum::response::Response> {
|
||||
let postcode = params.postcode.trim().to_uppercase();
|
||||
|
||||
let resp = state
|
||||
.http_client
|
||||
.get(TYPEAHEAD_URL)
|
||||
.query(&[("query", &postcode), ("limit", &"10".to_string())])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(error = %err, "Rightmove typeahead request failed");
|
||||
(StatusCode::BAD_GATEWAY, "Rightmove typeahead unavailable").into_response()
|
||||
})?;
|
||||
|
||||
let data: RightmoveTypeaheadResponse = resp.json().await.map_err(|err| {
|
||||
warn!(error = %err, "Failed to parse Rightmove typeahead response");
|
||||
(StatusCode::BAD_GATEWAY, "Invalid typeahead response").into_response()
|
||||
})?;
|
||||
|
||||
// Look for POSTCODE match first, then OUTCODE
|
||||
for match_type in &["POSTCODE", "OUTCODE"] {
|
||||
for m in &data.matches {
|
||||
if m.match_type == *match_type
|
||||
&& m.display_name.to_uppercase().replace(' ', "")
|
||||
== postcode.replace(' ', "")
|
||||
{
|
||||
let id = match &m.id {
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
return Ok(Json(TypeaheadResponse {
|
||||
location_identifier: format!("{}^{}", match_type, id),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("No Rightmove location found for: {}", postcode),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue