//! Cuisines: every restaurant of a kind, grouped by area — "where's the
//! ramen?", at home or on a trip. Cuisine is free text ("indian, nepalese"),
//! split into tags by `restaurants::cuisine_tags`.
use std::collections::{BTreeMap, HashMap};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::Html;
use chrono::NaiveDate;
use minijinja::context;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::lunch::{default_meal_time, today};
use super::{internal, render};
use crate::entities::rankings::Rank;
use crate::entities::{areas, lunches, rankings, restaurants};
use crate::middleware::UserContext;
use crate::{hours, AppState};
#[derive(Debug, Deserialize)]
pub struct CuisineQuery {
/// Limit to one area (and link back to it).
pub area: Option<Uuid>,
}
async fn in_rotation(
db: &DatabaseConnection,
area: Option<Uuid>,
) -> Result<Vec<restaurants::Model>, StatusCode> {
let mut q = restaurants::Entity::find().filter(restaurants::Column::Archived.eq(false));
if let Some(a) = area {
q = q.filter(restaurants::Column::AreaId.eq(a));
}
q.order_by_asc(restaurants::Column::Name).all(db).await.map_err(internal("list restaurants failed"))
}
async fn scope(db: &DatabaseConnection, area: Option<Uuid>) -> Result<Option<areas::Model>, StatusCode> {
match area {
Some(id) => Ok(Some(super::areas::load_area(db, id).await?)),
None => Ok(None),
}
}
pub async fn list_cuisines(
State(state): State<AppState>,
user: UserContext,
Query(qs): Query<CuisineQuery>,
) -> Result<Html<String>, StatusCode> {
let db = state.db.as_ref();
let area = scope(db, qs.area).await?;
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
let mut untagged = 0;
for r in in_rotation(db, qs.area).await? {
let tags = r.cuisine_tags();
if tags.is_empty() {
untagged += 1;
}
for t in tags {
*counts.entry(t).or_default() += 1;
}
}
let mut cuisines: Vec<(String, usize)> = counts.into_iter().collect();
cuisines.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let cuisines: Vec<minijinja::Value> =
cuisines.into_iter().map(|(name, count)| context! { name => name, count => count }).collect();
render(
&state,
"cuisines/list.html",
context! { title => "Cuisines", user => user.user, area => area, cuisines => cuisines, untagged => untagged },
)
}
#[derive(Serialize)]
struct Row {
restaurant: restaurants::Model,
cuisines: Vec<String>,
my_rank: Option<Rank>,
visits: usize,
last_visit: Option<NaiveDate>,
lunch_today: Option<hours::State>,
}
pub async fn show_cuisine(
State(state): State<AppState>,
user: UserContext,
Path(name): Path<String>,
Query(qs): Query<CuisineQuery>,
) -> Result<Html<String>, StatusCode> {
let db = state.db.as_ref();
let name = name.trim().to_lowercase();
let area = scope(db, qs.area).await?;
let matching: Vec<restaurants::Model> =
in_rotation(db, qs.area).await?.into_iter().filter(|r| r.cuisine_tags().contains(&name)).collect();
let ids: Vec<Uuid> = matching.iter().map(|r| r.id).collect();
let my_ranks: HashMap<Uuid, Rank> = rankings::Entity::find()
.filter(rankings::Column::UserId.eq(user.user.id))
.filter(rankings::Column::RestaurantId.is_in(ids.clone()))
.all(db)
.await
.map_err(internal("load rankings failed"))?
.into_iter()
.map(|r| (r.restaurant_id, r.rank()))
.collect();
let mut visits: HashMap<Uuid, Vec<NaiveDate>> = HashMap::new();
for l in lunches::Entity::find()
.filter(lunches::Column::RestaurantId.is_in(ids))
.all(db)
.await
.map_err(internal("load lunches failed"))?
{
visits.entry(l.restaurant_id).or_default().push(l.went_on);
}
let area_names: HashMap<Uuid, String> = super::areas::all_areas(db)
.await?
.into_iter()
.map(|a| (a.id, a.name))
.collect();
let lunch_today = today().and_time(default_meal_time());
let mut groups: BTreeMap<String, (Uuid, Vec<Row>)> = BTreeMap::new();
for r in matching {
let v = visits.get(&r.id);
let row = Row {
cuisines: r.cuisine_tags(),
my_rank: my_ranks.get(&r.id).copied(),
visits: v.map_or(0, Vec::len),
last_visit: v.and_then(|v| v.iter().max().copied()),
lunch_today: r.opening_hours.as_deref().map(|h| hours::state_at(h, lunch_today)),
restaurant: r,
};
let area_name = area_names.get(&row.restaurant.area_id).cloned().unwrap_or_default();
groups.entry(area_name).or_insert_with(|| (row.restaurant.area_id, vec![])).1.push(row);
}
let total: usize = groups.values().map(|g| g.1.len()).sum();
let groups: Vec<minijinja::Value> = groups
.into_iter()
.map(|(area_name, (area_id, rows))| context! { area_name => area_name, area_id => area_id, rows => rows })
.collect();
render(
&state,
"cuisines/show.html",
context! {
title => name.clone(),
user => user.user,
cuisine => name,
area => area,
groups => groups,
total => total,
},
)
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
5.3 KiB · text
History
6dd7389