#![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, pub week: Option, } #[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, /// 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, 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, pub rows: Vec, } /// 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) -> Vec { let mut out: Vec = Vec::new(); let mut run: Vec = 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, out: &mut Vec) { 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, q_week: Option, ) -> Result { 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 = 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 = (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 = (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, State(ctx): State, jar: CookieJar, Query(q): Query, ) -> Result { 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) -> Result { // 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)) }