Find similar nearby, distance-sorted search, |timesince filter

3d2ee7c · Gabriel Morell · 2026-10-28 20:51

18 files +448 -9
Message
{commit_body(@commit)}

Files changed

modified README.md
+11 −0
@@ -169,6 +169,17 @@ Restaurants added before hours were tracked can pick them up with
169 169 once under **Manage** on the area page. Refresh replaces hours (they change)
170 170 but only fills cuisine, website and address where blank.
171 171
172 +Search results from an area are sorted nearest its centre first, and
173 +anything over 50 km away is dropped (Photon will otherwise offer a namesake
174 +on another continent).
175 +
176 +**Similar nearby**, on a restaurant's page, lists places with the same
177 +cuisine: those already in the area, nearest first, and OSM places within
178 +1.5 or 5 km that aren't yet, which editors can add in one click. It asks
179 +`/poi/nearby` for the cuisine; when that's down, it searches Photon for the
180 +cuisine word and keeps only results whose tags really carry it, so places
181 +without the word in their name are missed until POI is back.
182 +
172 183 **Cuisines** (`/cuisines`, optionally `?area=`) lists every cuisine tag with
173 184 counts; each tag's page shows every matching restaurant grouped by area, with
174 185 your rank and whether it's open at noon today. Cuisine tags link there
modified src/bin/webservice.rs
+1 −0
@@ -118,6 +118,7 @@ async fn main() -> anyhow::Result<()> {
118 118 .route("/restaurants/{id}/rank", post(handlers::restaurants::set_rank))
119 119 .route("/restaurants/{id}/archive", post(handlers::restaurants::toggle_archive))
120 120 .route("/restaurants/{id}/refresh", post(handlers::restaurants::refresh_restaurant))
121 + .route("/restaurants/{id}/similar", get(handlers::restaurants::similar))
121 122 .route("/lunches", post(handlers::lunch::log_lunch))
122 123 .route("/lunches/{id}/delete", post(handlers::lunch::delete_lunch))
123 124 .layer(axum::middleware::from_fn_with_state(db.clone(), web_auth_middleware));
added src/filters.rs
+124 −0
@@ -0,0 +1,124 @@
1 +//! Custom minijinja filters.
2 +use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
3 +use minijinja::{Error, ErrorKind};
4 +
5 +/// Django's classic `|timesince`: how long ago `value` was, as the largest
6 +/// non-zero unit plus the next one down when that's non-zero too — "3 days",
7 +/// "2 weeks, 1 day", "1 year, 2 months". Years are 365 days and months 30,
8 +/// as in Django before calendar-aware months.
9 +///
10 +/// `value` (and the optional `now`, for comparing two times) is a date
11 +/// (`2026-10-20`) or a datetime (RFC 3339, or naive `2026-10-20T12:00:00`).
12 +/// Dates count in whole days, so a date today is "0 days" rather than the
13 +/// hours since midnight. Anything in the future is "0 minutes", or "0 days"
14 +/// for a date, as in Django.
15 +pub fn timesince(value: String, now: Option<String>) -> Result<String, Error> {
16 + let (then, date_only) = parse(&value)?;
17 + let now = match now {
18 + Some(n) => parse(&n)?.0,
19 + None => {
20 + if date_only {
21 + Utc::now().date_naive().and_hms_opt(0, 0, 0).expect("midnight").and_utc()
22 + } else {
23 + Utc::now()
24 + }
25 + }
26 + };
27 + Ok(since((now - then).num_seconds(), date_only))
28 +}
29 +
30 +fn parse(s: &str) -> Result<(DateTime<Utc>, bool), Error> {
31 + let s = s.trim();
32 + if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
33 + return Ok((d.and_hms_opt(0, 0, 0).expect("midnight").and_utc(), true));
34 + }
35 + if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
36 + return Ok((dt.with_timezone(&Utc), false));
37 + }
38 + for fmt in ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M"] {
39 + if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
40 + return Ok((dt.and_utc(), false));
41 + }
42 + }
43 + Err(Error::new(ErrorKind::InvalidOperation, format!("timesince: not a date or datetime: {s:?}")))
44 +}
45 +
46 +const MINUTE: i64 = 60;
47 +const HOUR: i64 = 60 * MINUTE;
48 +const DAY: i64 = 24 * HOUR;
49 +const CHUNKS: [(i64, &str); 6] = [
50 + (365 * DAY, "year"),
51 + (30 * DAY, "month"),
52 + (7 * DAY, "week"),
53 + (DAY, "day"),
54 + (HOUR, "hour"),
55 + (MINUTE, "minute"),
56 +];
57 +
58 +fn unit(count: i64, name: &str) -> String {
59 + format!("{count} {name}{}", if count == 1 { "" } else { "s" })
60 +}
61 +
62 +fn since(seconds: i64, date_only: bool) -> String {
63 + // Dates never resolve below a day.
64 + let chunks = if date_only { &CHUNKS[..4] } else { &CHUNKS[..] };
65 + let smallest = chunks[chunks.len() - 1].1;
66 + if seconds <= 0 {
67 + return unit(0, smallest);
68 + }
69 + let Some(i) = chunks.iter().position(|(s, _)| seconds / s != 0) else {
70 + return unit(0, smallest);
71 + };
72 + let (size, name) = chunks[i];
73 + let count = seconds / size;
74 + let mut out = unit(count, name);
75 + if let Some((size2, name2)) = chunks.get(i + 1) {
76 + let count2 = (seconds - size * count) / size2;
77 + if count2 != 0 {
78 + out.push_str(", ");
79 + out.push_str(&unit(count2, name2));
80 + }
81 + }
82 + out
83 +}
84 +
85 +#[cfg(test)]
86 +mod tests {
87 + use super::*;
88 +
89 + fn ts(value: &str, now: &str) -> String {
90 + timesince(value.into(), Some(now.into())).unwrap()
91 + }
92 +
93 + #[test]
94 + fn dates_count_whole_days() {
95 + assert_eq!(ts("2026-10-28", "2026-10-28"), "0 days");
96 + assert_eq!(ts("2026-10-27", "2026-10-28"), "1 day");
97 + assert_eq!(ts("2026-10-20", "2026-10-28"), "1 week, 1 day");
98 + assert_eq!(ts("2026-10-14", "2026-10-28"), "2 weeks");
99 + // 88 days: two 30-day months, then 28 days.
100 + assert_eq!(ts("2026-08-01", "2026-10-28"), "2 months, 4 weeks");
101 + // 787 days: two 365-day years, then 57 days.
102 + assert_eq!(ts("2024-09-01", "2026-10-28"), "2 years, 1 month");
103 + // 757 days: the remainder is under a month, so years alone.
104 + assert_eq!(ts("2024-10-01", "2026-10-28"), "2 years");
105 + // Future: zero, as Django does.
106 + assert_eq!(ts("2026-11-01", "2026-10-28"), "0 days");
107 + }
108 +
109 + #[test]
110 + fn datetimes_reach_minutes() {
111 + assert_eq!(ts("2026-10-28T11:15:00Z", "2026-10-28T12:00:00Z"), "45 minutes");
112 + assert_eq!(ts("2026-10-28T09:30:00Z", "2026-10-28T12:00:00Z"), "2 hours, 30 minutes");
113 + assert_eq!(ts("2026-10-28T11:59:30Z", "2026-10-28T12:00:00Z"), "0 minutes");
114 + // Offsets are honoured; naive datetimes are taken as UTC.
115 + assert_eq!(ts("2026-10-28T08:00:00-04:00", "2026-10-28 13:00:00"), "1 hour");
116 + // Only adjacent units: 1 day and 0 hours shows no minutes.
117 + assert_eq!(ts("2026-10-27T11:59:00Z", "2026-10-28T12:00:00Z"), "1 day");
118 + }
119 +
120 + #[test]
121 + fn rejects_non_dates() {
122 + assert!(timesince("yesterday".into(), None).is_err());
123 + }
124 +}
modified src/handlers/places.rs
+11 −1
@@ -20,6 +20,10 @@ use crate::middleware::UserContext;
20 20 use crate::osm::Place;
21 21 use crate::AppState;
22 22
23 +/// How far from an area's centre a search result may be. Generous enough
24 +/// for a metro area, tight enough to drop other continents.
25 +const SEARCH_RADIUS_M: f64 = 50_000.0;
26 +
23 27 #[derive(Debug, Deserialize)]
24 28 pub struct SearchQuery {
25 29 pub q: String,
@@ -54,7 +58,13 @@ pub async fn search(
54 58 }
55 59 }
56 60 };
57 − // Area searches ("Austin") have no cuisine to find.
61 + // Searching from an area: nearest first, and nothing absurdly far —
62 + // Photon will happily offer a namesake on another continent. An area's
63 + // own centre search ("Austin") has no point to measure from.
64 + if let Some((lat, lon)) = near {
65 + crate::osm::by_distance(&mut places, lat, lon, Some(SEARCH_RADIUS_M));
66 + }
67 + // Area searches have no cuisine to find.
58 68 if food {
59 69 state.osm.enrich(&mut places).await;
60 70 }
modified src/handlers/restaurants.rs
+83 −1
@@ -1,7 +1,7 @@
1 1 //! Restaurants: added from the map, ranked by each person.
2 2 use std::collections::HashMap;
3 3
4 −use axum::extract::{Form, Path, State};
4 +use axum::extract::{Form, Path, Query, State};
5 5 use axum::http::StatusCode;
6 6 use axum::response::{Html, Redirect};
7 7 use chrono::Utc;
@@ -365,6 +365,88 @@ pub async fn refresh_restaurant(
365 365 Ok(Redirect::to(&format!("/restaurants/{id}")))
366 366 }
367 367
368 +#[derive(Debug, Deserialize)]
369 +pub struct SimilarQuery {
370 + /// Metres; 1500 (a walk) unless asked.
371 + pub radius: Option<u32>,
372 +}
373 +
374 +/// htmx: places with the same cuisine around this one — those already in
375 +/// the area, nearest first, and OSM places nearby that aren't yet, which
376 +/// editors can add in one click.
377 +pub async fn similar(
378 + State(state): State<AppState>,
379 + user: UserContext,
380 + Path(id): Path<Uuid>,
381 + Query(q): Query<SimilarQuery>,
382 +) -> Result<Html<String>, StatusCode> {
383 + let db = state.db.as_ref();
384 + let (restaurant, role) = load_restaurant(db, id, user.user.id, Role::Voter).await?;
385 + let tags = restaurant.cuisine_tags();
386 + let radius = q.radius.unwrap_or(1500).clamp(250, 10_000);
387 +
388 + let others: Vec<restaurants::Model> = restaurants::Entity::find()
389 + .filter(restaurants::Column::AreaId.eq(restaurant.area_id))
390 + .filter(restaurants::Column::Archived.eq(false))
391 + .filter(restaurants::Column::Id.ne(id))
392 + .all(db)
393 + .await
394 + .map_err(internal("list restaurants failed"))?;
395 + let in_area_refs: std::collections::HashSet<String> =
396 + others.iter().filter_map(|r| r.osm_ref.clone()).chain(restaurant.osm_ref.clone()).collect();
397 + let my_ranks: HashMap<Uuid, Rank> = rankings::Entity::find()
398 + .filter(rankings::Column::UserId.eq(user.user.id))
399 + .filter(rankings::Column::RestaurantId.is_in(others.iter().map(|r| r.id)))
400 + .all(db)
401 + .await
402 + .map_err(internal("load rankings failed"))?
403 + .into_iter()
404 + .map(|r| (r.restaurant_id, r.rank()))
405 + .collect();
406 + let mut in_area: Vec<(i64, minijinja::Value)> = others
407 + .iter()
408 + .filter(|r| r.cuisine_tags().iter().any(|t| tags.contains(t)))
409 + .map(|r| {
410 + let d = crate::osm::haversine_m(restaurant.lat, restaurant.lon, r.lat, r.lon).round() as i64;
411 + (d, context! { restaurant => r, distance_m => d, my_rank => my_ranks.get(&r.id), cuisines => r.cuisine_tags() })
412 + })
413 + .collect();
414 + in_area.sort_by_key(|(d, _)| *d);
415 +
416 + let nearby: Vec<crate::osm::Place> = if tags.is_empty() {
417 + vec![]
418 + } else {
419 + state
420 + .osm
421 + .similar(restaurant.lat, restaurant.lon, radius, &tags)
422 + .await
423 + .into_iter()
424 + .filter(|p| p.osm_ref.as_ref().is_none_or(|r| !in_area_refs.contains(r)))
425 + // Hand-added restaurants have no OSM ref to match on; the same
426 + // name at (nearly) the same spot is the same place.
427 + .filter(|p| {
428 + !others.iter().chain(std::iter::once(&restaurant)).any(|r| {
429 + r.name.eq_ignore_ascii_case(&p.name)
430 + && crate::osm::haversine_m(r.lat, r.lon, p.lat, p.lon) < 150.0
431 + })
432 + })
433 + .collect()
434 + };
435 +
436 + render(
437 + &state,
438 + "restaurants/_similar.html",
439 + context! {
440 + restaurant => restaurant,
441 + cuisines => tags,
442 + radius => radius,
443 + in_area => in_area.into_iter().map(|(_, v)| v).collect::<Vec<_>>(),
444 + nearby => nearby,
445 + can_add => role >= Role::Editor,
446 + },
447 + )
448 +}
449 +
368 450 /// Closed for good, or just off the rotation: archived places stay in the
369 451 /// history but are never suggested.
370 452 pub async fn toggle_archive(
modified src/lib.rs
+2 −0

Click to load diff…

modified src/osm.rs
+87 −6

Click to load diff…

modified templates/areas/show.html
+6 −1

Click to load diff…

added templates/restaurants/_similar.html
+56 −0

Click to load diff…

modified templates/restaurants/show.html
+25 −0

Click to load diff…

modified tests/templates.rs
+42 −0

Click to load diff…

Parents: aecd85b