Good changes

This commit is contained in:
Andras Schmelczer 2026-03-11 20:44:34 +00:00
parent 80a5a2a774
commit 791bc6976b
24 changed files with 890 additions and 312 deletions

View file

@ -8,11 +8,13 @@ use polars::lazy::frame::LazyFrame;
use rustc_hash::{FxHashMap, FxHashSet};
use tracing::info;
/// Per-postcode travel time data: median and optional best-case (transit only).
#[derive(Clone, Copy)]
/// Per-postcode travel time data: median, optional best-case (transit only),
/// and optional journey instructions (JSON leg array, transit only with --paths).
#[derive(Clone)]
pub struct TravelDataRow {
pub minutes: i16,
pub best_minutes: Option<i16>,
pub journey: Option<Arc<str>>,
}
/// Cached postcode → travel time data for a single destination file.
@ -198,17 +200,26 @@ impl TravelTimeStore {
.column("best_minutes")
.ok()
.map(|col| col.i16().expect("'best_minutes' is not i16"));
let journeys = df
.column("journey")
.ok()
.map(|col| col.str().expect("'journey' is not string"));
let mut map = FxHashMap::default();
map.reserve(df.height());
for (i, (pc, min)) in postcodes.into_iter().zip(minutes.into_iter()).enumerate() {
if let (Some(pc), Some(min)) = (pc, min) {
let best_min = best.as_ref().and_then(|b| b.get(i));
let journey = journeys
.as_ref()
.and_then(|j| j.get(i))
.map(Arc::from);
map.insert(
pc.to_string(),
TravelDataRow {
minutes: min,
best_minutes: best_min,
journey,
},
);
}

View file

@ -71,6 +71,10 @@ pub struct HexagonStatsParams {
/// Comma-separated feature names to include in stats response.
/// Only listed features are computed; if absent or empty, no features are returned.
pub fields: Option<String>,
/// When set (with journey_slug), pick central_postcode as the postcode with the
/// shortest travel time for this mode+slug (so it has journey data).
pub journey_mode: Option<String>,
pub journey_slug: Option<String>,
}
pub async fn get_hexagon_stats(
@ -107,6 +111,17 @@ pub async fn get_hexagon_stats(
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
// Load travel time data for central_postcode selection (if requested)
let journey_travel_data = match (&params.journey_mode, &params.journey_slug) {
(Some(mode), Some(slug)) if state.travel_time_store.has_destination(mode, slug) => {
state
.travel_time_store
.get(mode, slug)
.ok()
}
_ => None,
};
let response = tokio::task::spawn_blocking(move || {
let start_time = std::time::Instant::now();
let precomputed = &state.h3_cells;
@ -138,27 +153,58 @@ pub async fn get_hexagon_stats(
let total_count = matching_rows.len();
// Find the postcode of the property closest to the hexagon center
// Pick central_postcode: prefer the postcode with the shortest travel time
// for the requested journey destination (so it has journey data). Fall back
// to geographic proximity 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())
if let Some(ref travel_data) = journey_travel_data {
// Find the row with the shortest travel time in the travel data
let best_row = matching_rows
.iter()
.copied()
.filter_map(|row| {
let pc = state.data.postcode(row);
travel_data.get(pc).map(|td| (row, td.minutes))
})
.min_by_key(|&(_, mins)| mins)
.map(|(row, _)| row);
// Fall back to geographic center if no row has travel data
let row = best_row.unwrap_or_else(|| {
let center: h3o::LatLng = cell.into();
let center_lat = center.lat() as f32;
let center_lon = center.lng() as f32;
matching_rows
.iter()
.copied()
.min_by(|&a, &b| {
let da = (state.data.lat[a] - center_lat).powi(2)
+ (state.data.lon[a] - center_lon).powi(2);
let db = (state.data.lat[b] - center_lat).powi(2)
+ (state.data.lon[b] - center_lon).powi(2);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.expect("matching_rows is non-empty")
});
Some(state.data.postcode(row).to_string())
} else {
// No journey destination requested — use geographic center
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 = (state.data.lat[a] - center_lat).powi(2)
+ (state.data.lon[a] - center_lon).powi(2);
let db = (state.data.lat[b] - center_lat).powi(2)
+ (state.data.lon[b] - center_lon).powi(2);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.expect("matching_rows is non-empty");
Some(state.data.postcode(closest_row).to_string())
}
} else {
None
};

View file

@ -25,6 +25,12 @@ struct InviteValidation {
used: bool,
}
#[derive(Deserialize)]
pub struct CreateInviteRequest {
/// Admins can explicitly choose "admin" or "referral". Ignored for non-admins.
invite_type: Option<String>,
}
#[derive(Deserialize)]
pub struct RedeemRequest {
code: String,
@ -66,12 +72,12 @@ fn generate_invite_code() -> String {
chars.into_iter().collect()
}
/// Create an invite. Admins create "admin" invites (free license).
/// Licensed non-admin users create "referral" invites (30% off).
/// Create an invite. Admins create "admin" invites (free license) by default,
/// but can explicitly request "referral" type. Licensed non-admin users always create "referral" invites (30% off).
pub async fn post_invites(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
_body: Json<serde_json::Value>,
Json(body): Json<CreateInviteRequest>,
) -> Response {
let user = match user.0 {
Some(u) => u,
@ -79,7 +85,10 @@ pub async fn post_invites(
};
let invite_type = if user.is_admin {
"admin"
match body.invite_type.as_deref() {
Some("referral") => "referral",
_ => "admin",
}
} else if user.subscription == "licensed" {
"referral"
} else {

View file

@ -0,0 +1,58 @@
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use crate::state::AppState;
#[derive(Deserialize)]
pub struct JourneyQuery {
postcode: String,
mode: String,
slug: String,
}
#[derive(Serialize)]
pub struct JourneyResponse {
/// Raw JSON array of journey legs, or null if no journey data available.
journey: Option<serde_json::Value>,
/// Median (50th percentile) total travel time in minutes.
minutes: Option<i16>,
/// Best-case (5th percentile) total travel time in minutes (transit only).
best_minutes: Option<i16>,
}
pub async fn get_journey(
state: Arc<AppState>,
query: axum::extract::Query<JourneyQuery>,
) -> Result<Json<JourneyResponse>, (StatusCode, String)> {
let store = &state.travel_time_store;
if !store.has_destination(&query.mode, &query.slug) {
return Err((
StatusCode::NOT_FOUND,
format!("No travel data for mode={} slug={}", query.mode, query.slug),
));
}
let travel_data = store.get(&query.mode, &query.slug).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to load travel data: {e}"),
)
})?;
let row = travel_data.get(&query.postcode);
let journey = row
.and_then(|r| r.journey.as_ref())
.and_then(|j| serde_json::from_str::<serde_json::Value>(j).ok());
let minutes = row.map(|r| r.minutes);
let best_minutes = row.and_then(|r| r.best_minutes);
Ok(Json(JourneyResponse {
journey,
minutes,
best_minutes,
}))
}

View file

@ -1,83 +0,0 @@
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())
}

View file

@ -1,78 +0,0 @@
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use serde::Deserialize;
use tracing::warn;
use crate::auth::OptionalUser;
use crate::pocketbase::auth_superuser;
use crate::state::AppState;
const VALID_SUBSCRIPTIONS: &[&str] = &["free", "licensed"];
#[derive(Deserialize)]
pub struct UpdateSubscriptionRequest {
subscription: String,
}
pub async fn patch_subscription(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Json(req): Json<UpdateSubscriptionRequest>,
) -> Response {
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
if !user.is_admin {
return StatusCode::FORBIDDEN.into_response();
}
if !VALID_SUBSCRIPTIONS.contains(&req.subscription.as_str()) {
return (
StatusCode::BAD_REQUEST,
format!("Invalid subscription: {}", req.subscription),
)
.into_response();
}
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
{
Ok(t) => t,
Err(err) => {
warn!("Failed to authenticate as PocketBase superuser: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let url = format!("{pb_url}/api/collections/users/records/{}", user.id);
let res = state
.http_client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "subscription": req.subscription }))
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {
state.token_cache.invalidate_by_user_id(&user.id);
StatusCode::OK.into_response()
}
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!("PocketBase user update failed ({status}): {text}");
StatusCode::BAD_GATEWAY.into_response()
}
Err(err) => {
warn!("PocketBase request error: {err}");
StatusCode::BAD_GATEWAY.into_response()
}
}
}

View file

@ -0,0 +1,89 @@
use std::sync::Arc;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::data::slugify;
use crate::state::AppState;
#[derive(Serialize)]
pub struct DestinationResult {
name: String,
slug: String,
place_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
city: Option<String>,
}
#[derive(Serialize)]
pub struct DestinationsResponse {
destinations: Vec<DestinationResult>,
}
#[derive(Deserialize)]
pub struct DestinationsParams {
mode: String,
}
pub async fn get_travel_destinations(
state: Arc<AppState>,
Query(params): Query<DestinationsParams>,
) -> Result<Json<DestinationsResponse>, (StatusCode, String)> {
let mode = params.mode;
let destinations = tokio::task::spawn_blocking(move || {
let t0 = std::time::Instant::now();
let pd = &state.place_data;
let tt_store = &state.travel_time_store;
let slug_set = match tt_store.destinations.get(&mode) {
Some(slugs) => slugs,
None => return Vec::new(),
};
// Find places that have travel time data for this mode
let mut matches: Vec<(usize, String, u8, u32, usize)> = pd
.name
.iter()
.enumerate()
.filter_map(|(idx, name)| {
let slug = slugify(name);
if slug_set.contains(&slug) {
Some((idx, slug, pd.type_rank[idx], pd.population[idx], name.len()))
} else {
None
}
})
.collect();
// Sort: type rank asc, population desc, name length asc
matches.sort_unstable_by(|a, b| a.2.cmp(&b.2).then(b.3.cmp(&a.3)).then(a.4.cmp(&b.4)));
let results: Vec<DestinationResult> = matches
.into_iter()
.map(|(idx, slug, ..)| DestinationResult {
name: pd.name[idx].clone(),
slug,
place_type: pd.place_type.get(idx).to_string(),
city: pd.city[idx].clone(),
})
.collect();
let elapsed = t0.elapsed();
info!(
mode = mode.as_str(),
results = results.len(),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/travel-destinations"
);
results
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
Ok(Json(DestinationsResponse { destinations }))
}