360 lines
12 KiB
Rust
360 lines
12 KiB
Rust
#![allow(clippy::missing_errors_doc)]
|
||
#![allow(clippy::unused_async)]
|
||
//! Public, read-only week-view calendar.
|
||
//!
|
||
//! Shows a 7-day grid (one column per day, one row per hour) for a single
|
||
//! court. Booked slots are coloured; free slots are blank. The same grid is
|
||
//! reused by the admin dashboard with `is_admin = true`.
|
||
|
||
use axum::http::{header, HeaderMap};
|
||
use axum::response::Redirect;
|
||
use axum_extra::extract::cookie::CookieJar;
|
||
use chrono::{Datelike, Duration, NaiveDate, Utc};
|
||
use loco_rs::prelude::*;
|
||
use sea_orm::QueryOrder;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::models::_entities::{bookings, courts};
|
||
|
||
/// First bookable hour (06:00).
|
||
pub const FIRST_HOUR: i32 = 6;
|
||
/// Last bookable hour slot start (21:00 — i.e. the 21:00-22:00 slot).
|
||
pub const LAST_HOUR: i32 = 21;
|
||
|
||
const DAY_KEYS: [&str; 7] = [
|
||
"day-mon", "day-tue", "day-wed", "day-thu", "day-fri", "day-sat", "day-sun",
|
||
];
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct CalQuery {
|
||
pub court: Option<i32>,
|
||
pub week: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct CourtOpt {
|
||
pub id: i32,
|
||
pub name: String,
|
||
pub selected: bool,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct DayHead {
|
||
pub date: String,
|
||
pub key: String,
|
||
pub num: u32,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct Cell {
|
||
pub date: String,
|
||
pub hour: i32,
|
||
pub booked: bool,
|
||
pub color: String,
|
||
pub booking_id: i32,
|
||
/// Private — admin only. Never rendered on the public calendar.
|
||
pub name: String,
|
||
/// Public-facing label shown on the calendar when set.
|
||
pub title: String,
|
||
/// Private — admin only. Shown in the dashboard's detailed view.
|
||
pub contact: String,
|
||
/// Private — admin only. Shown in the dashboard's detailed view.
|
||
pub note: String,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct Row {
|
||
pub hour_label: String,
|
||
pub cells: Vec<Cell>,
|
||
/// Public view only: `true` when this row collapses a run of fully-free
|
||
/// hours into one compact strip. The admin grid never sets it.
|
||
pub free_group: bool,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct CalendarPage {
|
||
pub lang: String,
|
||
/// `true` only on the admin dashboard — enables the editable cells.
|
||
pub is_admin: bool,
|
||
/// `true` whenever the admin is signed in — controls the nav links.
|
||
pub logged_in: bool,
|
||
pub base_path: String,
|
||
pub has_courts: bool,
|
||
pub courts: Vec<CourtOpt>,
|
||
pub court_id: i32,
|
||
pub court_name: String,
|
||
pub week: String,
|
||
pub week_label: String,
|
||
pub prev_week: String,
|
||
pub this_week: String,
|
||
pub next_week: String,
|
||
/// `false` on the public calendar once it reaches the two-week look-back
|
||
/// limit, which disables the "previous week" button. Always `true` for admin.
|
||
pub can_prev: bool,
|
||
/// Index (0–6, Mon–Sun) of the day shown first in the mobile single-day
|
||
/// view — today when the displayed week contains it, otherwise Monday.
|
||
pub mobile_day: i64,
|
||
pub days: Vec<DayHead>,
|
||
pub rows: Vec<Row>,
|
||
}
|
||
|
||
/// Resolves the UI language from the `lang` cookie. Slovak is the default;
|
||
/// English applies only when the cookie explicitly asks for it.
|
||
#[must_use]
|
||
pub fn current_lang(jar: &CookieJar) -> String {
|
||
match jar.get("lang").map(|c| c.value().to_string()) {
|
||
Some(ref l) if l == "en" => "en".to_string(),
|
||
_ => "sk".to_string(),
|
||
}
|
||
}
|
||
|
||
fn monday_of(date: NaiveDate) -> NaiveDate {
|
||
date - Duration::days(i64::from(date.weekday().num_days_from_monday()))
|
||
}
|
||
|
||
fn week_monday(week: Option<&str>) -> NaiveDate {
|
||
let base = week
|
||
.and_then(|w| NaiveDate::parse_from_str(w, "%Y-%m-%d").ok())
|
||
.unwrap_or_else(|| Utc::now().date_naive());
|
||
monday_of(base)
|
||
}
|
||
|
||
/// Collapses runs of fully-free hour rows into one compact strip so a court
|
||
/// that isn't booked solid doesn't waste vertical space. Rows holding at
|
||
/// least one booking are kept full-size so bookings stay easy to read.
|
||
/// Public calendar only — the admin grid always shows every hour.
|
||
fn group_free_rows(rows: Vec<Row>) -> Vec<Row> {
|
||
let mut out: Vec<Row> = Vec::new();
|
||
let mut run: Vec<Row> = Vec::new();
|
||
for row in rows {
|
||
if row.cells.iter().all(|c| !c.booked) {
|
||
run.push(row);
|
||
} else {
|
||
flush_free_run(&mut run, &mut out);
|
||
out.push(row);
|
||
}
|
||
}
|
||
flush_free_run(&mut run, &mut out);
|
||
out
|
||
}
|
||
|
||
/// Drains a pending run of free rows into `out` as a single collapsed row.
|
||
fn flush_free_run(run: &mut Vec<Row>, out: &mut Vec<Row>) {
|
||
if run.is_empty() {
|
||
return;
|
||
}
|
||
let hour_of = |r: &Row| r.cells.first().map_or(FIRST_HOUR, |c| c.hour);
|
||
let first = run.first().map_or(FIRST_HOUR, hour_of);
|
||
let last = run.last().map_or(first, hour_of);
|
||
let hour_label = if run.len() > 1 {
|
||
format!("{first:02}:00–{:02}:00", last + 1)
|
||
} else {
|
||
format!("{first:02}:00")
|
||
};
|
||
out.push(Row {
|
||
hour_label,
|
||
cells: Vec::new(),
|
||
free_group: true,
|
||
});
|
||
run.clear();
|
||
}
|
||
|
||
/// Builds the calendar grid for the selected court and week.
|
||
pub async fn build_calendar(
|
||
ctx: &AppContext,
|
||
lang: &str,
|
||
is_admin: bool,
|
||
logged_in: bool,
|
||
q_court: Option<i32>,
|
||
q_week: Option<String>,
|
||
) -> Result<CalendarPage> {
|
||
let court_list = courts::Entity::find()
|
||
.order_by_asc(courts::Column::Id)
|
||
.all(&ctx.db)
|
||
.await?;
|
||
|
||
let selected = q_court
|
||
.filter(|id| court_list.iter().any(|c| c.id == *id))
|
||
.or_else(|| court_list.first().map(|c| c.id))
|
||
.unwrap_or(0);
|
||
|
||
let courts_opts: Vec<CourtOpt> = court_list
|
||
.iter()
|
||
.map(|c| CourtOpt {
|
||
id: c.id,
|
||
name: c.name.clone().unwrap_or_else(|| format!("Court {}", c.id)),
|
||
selected: c.id == selected,
|
||
})
|
||
.collect();
|
||
|
||
let court_name = courts_opts
|
||
.iter()
|
||
.find(|c| c.selected)
|
||
.map(|c| c.name.clone())
|
||
.unwrap_or_default();
|
||
|
||
let monday = week_monday(q_week.as_deref());
|
||
// The public calendar may look back at most two weeks; the admin has no limit.
|
||
let min_monday = monday_of(Utc::now().date_naive()) - Duration::weeks(2);
|
||
let monday = if is_admin { monday } else { monday.max(min_monday) };
|
||
let can_prev = is_admin || monday > min_monday;
|
||
// The mobile day-window opens on the requested day when a week was given
|
||
// (so returning from the booking editor lands back on it), else on today.
|
||
let pivot = q_week
|
||
.as_deref()
|
||
.and_then(|w| NaiveDate::parse_from_str(w, "%Y-%m-%d").ok())
|
||
.unwrap_or_else(|| Utc::now().date_naive());
|
||
let day_offset = (pivot - monday).num_days();
|
||
let mobile_day = if (0..7).contains(&day_offset) { day_offset } else { 0 };
|
||
let sunday = monday + Duration::days(6);
|
||
|
||
let days: Vec<DayHead> = (0..7i64)
|
||
.map(|i| {
|
||
let d = monday + Duration::days(i);
|
||
DayHead {
|
||
date: d.format("%Y-%m-%d").to_string(),
|
||
key: DAY_KEYS[i as usize].to_string(),
|
||
num: d.day(),
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
let week_bookings = if selected == 0 {
|
||
Vec::new()
|
||
} else {
|
||
bookings::Entity::find()
|
||
.filter(bookings::Column::CourtId.eq(selected))
|
||
.filter(bookings::Column::Date.gte(monday))
|
||
.filter(bookings::Column::Date.lte(sunday))
|
||
.all(&ctx.db)
|
||
.await?
|
||
};
|
||
|
||
let rows: Vec<Row> = (FIRST_HOUR..=LAST_HOUR)
|
||
.map(|hour| {
|
||
let cells = (0..7i64)
|
||
.map(|i| {
|
||
let d = monday + Duration::days(i);
|
||
let iso = d.format("%Y-%m-%d").to_string();
|
||
match week_bookings.iter().find(|b| b.date == d && b.hour == hour) {
|
||
Some(b) => Cell {
|
||
date: iso,
|
||
hour,
|
||
booked: true,
|
||
color: b.color.clone(),
|
||
booking_id: b.id,
|
||
name: b.name.clone(),
|
||
title: b.title.clone().unwrap_or_default(),
|
||
contact: b.contact.clone().unwrap_or_default(),
|
||
note: b.note.clone().unwrap_or_default(),
|
||
},
|
||
None => Cell {
|
||
date: iso,
|
||
hour,
|
||
booked: false,
|
||
color: String::new(),
|
||
booking_id: 0,
|
||
name: String::new(),
|
||
title: String::new(),
|
||
contact: String::new(),
|
||
note: String::new(),
|
||
},
|
||
}
|
||
})
|
||
.collect();
|
||
Row {
|
||
hour_label: format!("{hour:02}:00"),
|
||
cells,
|
||
free_group: false,
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
// The public calendar collapses empty stretches to stay compact; the admin
|
||
// grid always shows every hour so each slot stays individually editable.
|
||
let rows = if is_admin { rows } else { group_free_rows(rows) };
|
||
|
||
Ok(CalendarPage {
|
||
lang: lang.to_string(),
|
||
is_admin,
|
||
logged_in,
|
||
base_path: if is_admin { "/admin" } else { "/" }.to_string(),
|
||
has_courts: !courts_opts.is_empty(),
|
||
courts: courts_opts,
|
||
court_id: selected,
|
||
court_name,
|
||
week: monday.format("%Y-%m-%d").to_string(),
|
||
week_label: format!("{} – {}", monday.format("%d %b"), sunday.format("%d %b %Y")),
|
||
prev_week: (monday - Duration::days(7)).format("%Y-%m-%d").to_string(),
|
||
this_week: monday_of(Utc::now().date_naive())
|
||
.format("%Y-%m-%d")
|
||
.to_string(),
|
||
next_week: (monday + Duration::days(7)).format("%Y-%m-%d").to_string(),
|
||
can_prev,
|
||
mobile_day,
|
||
days,
|
||
rows,
|
||
})
|
||
}
|
||
|
||
#[debug_handler]
|
||
pub async fn index(
|
||
ViewEngine(v): ViewEngine<TeraView>,
|
||
State(ctx): State<AppContext>,
|
||
jar: CookieJar,
|
||
Query(q): Query<CalQuery>,
|
||
) -> Result<Response> {
|
||
let lang = current_lang(&jar);
|
||
// The public calendar is read-only for everyone, admin included. When an
|
||
// admin is signed in we still flag it so the nav keeps the admin links.
|
||
let logged_in = crate::controllers::admin::current_admin(&ctx, &jar)
|
||
.await
|
||
.is_some();
|
||
let page = build_calendar(&ctx, &lang, false, logged_in, q.court, q.week).await?;
|
||
format::render().view(&v, "calendar/week.html", &page)
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct LangForm {
|
||
pub lang: String,
|
||
}
|
||
|
||
/// Switches the UI language. The navbar's language buttons post here; the
|
||
/// `lang` cookie is set server-side and the visitor is bounced back to the
|
||
/// page they came from. This replaces the old client-side `setLang` script.
|
||
#[debug_handler]
|
||
pub async fn set_lang(headers: HeaderMap, Form(form): Form<LangForm>) -> Result<Response> {
|
||
// Only the two supported languages; anything else falls back to Slovak,
|
||
// matching `current_lang`.
|
||
let lang = if form.lang == "en" { "en" } else { "sk" };
|
||
let cookie = format!("lang={lang}; Path=/; Max-Age=31536000; SameSite=Lax");
|
||
Ok((
|
||
[(header::SET_COOKIE, cookie)],
|
||
Redirect::to(&back_path(&headers)),
|
||
)
|
||
.into_response())
|
||
}
|
||
|
||
/// On-site path of the page that submitted the form, read from `Referer`.
|
||
/// Scheme and host are stripped so a stale or foreign header can only ever
|
||
/// bounce the visitor to a path on this site, never off it.
|
||
fn back_path(headers: &HeaderMap) -> String {
|
||
let raw = headers
|
||
.get(header::REFERER)
|
||
.and_then(|v| v.to_str().ok())
|
||
.unwrap_or("/");
|
||
match raw.split_once("://") {
|
||
Some((_, rest)) => match rest.find('/') {
|
||
Some(i) => rest[i..].to_string(),
|
||
None => "/".to_string(),
|
||
},
|
||
None => raw.to_string(),
|
||
}
|
||
}
|
||
|
||
pub fn routes() -> Routes {
|
||
Routes::new()
|
||
.add("/", get(index))
|
||
.add("/lang", post(set_lang))
|
||
}
|